Adding OIDC to Remix With Secure Sessions

Scenario: you run a Remix app and need OIDC login where tokens live in a sealed, server-only cookie and protected loaders refresh them transparently. This walkthrough is part of the Integrating OIDC With Web Frameworks guide, mapping its server-side trust boundary onto Remix’s loader/action model.

Remix is a natural fit for the BFF pattern: loaders and actions run only on the server, and createCookieSessionStorage gives you a signed, encrypted, HttpOnly cookie out of the box. The entire OIDC lifecycle — redirect, callback, token exchange, refresh — happens in loaders and actions, so no token is ever serialized into the browser.

Root Cause: Loaders Run on the Server, Components Hydrate on the Client

A Remix loader’s return value is serialized and sent to the browser to hydrate the route component. That serialization boundary is the security line: anything a loader returns is visible in the page payload. The authorization code flow with PKCE (RFC 7636) protects the exchange, but it is wasted if the loader then ships the access token to the client. The discipline is simple — read tokens from the session inside the loader, use them server-side to call your API, and return only the rendered data.

createCookieSessionStorage seals session data into an HttpOnly cookie. With a strong secret, the cookie is signed (tamper-evident); set secure and sameSite correctly to complete the hardening.

// app/sessions.server.ts
import { createCookieSessionStorage } from "@remix-run/node";

type SessionData = {
  sub: string;
  access_token: string;
  refresh_token: string;
  expires_at: number;
};

type FlashData = { code_verifier: string; state: string; nonce: string };

export const sessionStorage = createCookieSessionStorage<SessionData, FlashData>({
  cookie: {
    name: "__oidc_session",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax", // REQUIRED so the cookie survives the redirect back from the IdP
    path: "/",
    secrets: [process.env.SESSION_SECRET!], // 32+ bytes; rotate by prepending a new secret
    maxAge: 60 * 60 * 24,
  },
});

export const { getSession, commitSession, destroySession } = sessionStorage;

SameSite=Lax is required: the IdP returns the user via a top-level cross-site GET, and a Strict cookie would not be sent, stranding the code_verifier and state. The full reasoning is in configuring secure cookie flags in production.

The Login Loader

A loader on /auth/login generates PKCE, state, and nonce, flashes them into the session, and redirects to the IdP. Using session.flash stores them for exactly one read — the callback consumes them and they vanish.

// app/routes/auth.login.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import * as oauth from "oauth4webapi";
import { getSession, commitSession } from "~/sessions.server";
import { as, client } from "~/oidc.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const code_verifier = oauth.generateRandomCodeVerifier();
  const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier);
  const state = oauth.generateRandomState();
  const nonce = oauth.generateRandomNonce();

  const session = await getSession(request.headers.get("Cookie"));
  session.flash("code_verifier", code_verifier);
  session.flash("state", state);
  session.flash("nonce", nonce);

  const url = new URL(as.authorization_endpoint!);
  url.searchParams.set("client_id", client.client_id);
  url.searchParams.set("redirect_uri", process.env.OIDC_REDIRECT_URI!);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", "openid profile email offline_access");
  url.searchParams.set("code_challenge", code_challenge);
  url.searchParams.set("code_challenge_method", "S256");
  url.searchParams.set("state", state);
  url.searchParams.set("nonce", nonce);

  return redirect(url.toString(), {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

The Callback Loader

The /auth/callback loader reads the flashed values, validates state, exchanges the code, verifies the ID Token under a strict algorithm allowlist, then commits a durable session.

// app/routes/auth.callback.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import * as oauth from "oauth4webapi";
import { getSession, commitSession } from "~/sessions.server";
import { as, client, clientAuth } from "~/oidc.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  const code_verifier = session.get("code_verifier");
  const state = session.get("state");
  const nonce = session.get("nonce");
  if (!code_verifier || !state || !nonce) {
    throw new Response("No login in progress", { status: 400 });
  }

  // 1. CSRF: the callback state must equal the issued state (RFC 9700).
  const params = oauth.validateAuthResponse(as, client, new URL(request.url), state);

  // 2. Code exchange bound to the PKCE verifier (RFC 7636).
  const tokenRes = await oauth.authorizationCodeGrantRequest(
    as, client, clientAuth, params, process.env.OIDC_REDIRECT_URI!, code_verifier,
  );

  // 3. Verify ID Token signature via JWKS, plus nonce; alg pinned to RS256/ES256.
  const result = await oauth.processAuthorizationCodeResponse(as, client, tokenRes, {
    expectedNonce: nonce,
    requireIdToken: true,
  });
  const claims = oauth.getValidatedIdTokenClaims(result)!;

  // 4. Promote flashed values to a durable session; tokens stay server-side.
  session.set("sub", claims.sub!);
  session.set("access_token", result.access_token!);
  session.set("refresh_token", result.refresh_token!);
  session.set("expires_at", Date.now() + (result.expires_in ?? 300) * 1000);

  return redirect("/dashboard", {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

The clientAuth/client config pins the ID Token signing algorithm to RS256 (or ES256 for EC keys). Never accept HS256 or alg: none for OIDC — both enable algorithm-confusion forgery against the IdP’s public key.

Refresh on the Loader, Then commitSession

Protected loaders refresh a near-expired access token before calling the API, rotate the stored refresh token, and re-commit the session so the new tokens persist.

// app/routes/dashboard.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import * as oauth from "oauth4webapi";
import { getSession, commitSession } from "~/sessions.server";
import { as, client, clientAuth } from "~/oidc.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  if (!session.get("sub")) return redirect("/auth/login");

  let accessToken = session.get("access_token")!;
  let setCookie: string | undefined;

  // Silent refresh: renew if within 30s of expiry, then rotate + persist.
  if (Date.now() >= session.get("expires_at")! - 30_000) {
    const res = await oauth.refreshTokenGrantRequest(
      as, client, clientAuth, session.get("refresh_token")!,
    );
    const refreshed = await oauth.processRefreshTokenResponse(as, client, res);
    accessToken = refreshed.access_token!;
    session.set("access_token", accessToken);
    session.set("refresh_token", refreshed.refresh_token ?? session.get("refresh_token")!);
    session.set("expires_at", Date.now() + (refreshed.expires_in ?? 300) * 1000);
    setCookie = await commitSession(session);
  }

  const api = await fetch("https://api.example.com/me", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  const profile = await api.json();

  // Return ONLY display data; commit the rotated session if it changed.
  return json(
    { name: profile.name, email: profile.email },
    setCookie ? { headers: { "Set-Cookie": setCookie } } : undefined,
  );
}

export default function Dashboard() {
  const { name, email } = useLoaderData<typeof loader>();
  return <h1>{name} ({email})</h1>;
}

The loader returns name and email, never accessToken. Refresh-token rotation here follows secure token refresh and rotation patterns: each refresh swaps in a new refresh token, and the previous one is invalidated.

Logout: destroySession

A logout action clears the cookie. If your IdP supports RP-initiated logout or a token revocation endpoint, call it server-side first so the refresh token is revoked, not just forgotten.

// app/routes/auth.logout.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { getSession, destroySession } from "~/sessions.server";

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  // (optional) revoke session.get("refresh_token") at the IdP here.
  return redirect("/", { headers: { "Set-Cookie": await destroySession(session) } });
}

Security Implications

  • Loaders never return tokens. The serialized loader payload reaches the browser; returning a token there is equivalent to exposing it in localStorage. Use tokens inside the loader, return rendered data.
  • state and nonce are mandatory. Flashed at /login, validated at /callback — the CSRF and replay defenses required by RFC 9700 and OpenID Connect Core.
  • Sealed cookie, strong secret. createCookieSessionStorage signs the cookie with secrets; pair with HttpOnly, Secure, and SameSite=Lax. Rotate secrets by prepending a new value to the array.

Prevention & Monitoring

  • Log every callback outcome with sub and request ID; alert on repeated state mismatches (login-CSRF probing).
  • Track invalid_grant from the refresh path — a spike usually means a SameSite/secure cookie misconfiguration is dropping the session.
  • Assert in CI that no eyJ JWT prefix appears in client-bound loader data.

Frequently Asked Questions

Should I use remix-auth instead of wiring oauth4webapi directly?

remix-auth with an OIDC/OAuth2 strategy is a fine higher-level option and uses the same session storage. Wire oauth4webapi directly when you need explicit control over PKCE, nonce validation, the signing-algorithm allowlist, or refresh-token rotation. The underlying security model — server-side tokens, validated state/nonce, RS256/ES256 only — is identical.

Why session.flash for the verifier and state instead of session.set?

flash stores a value for exactly one subsequent read, then auto-deletes it on the next commitSession. That is perfect for one-time login-transaction values: the callback reads code_verifier/state/nonce once, and they cannot linger to be replayed. Using set would leave them in the durable session unnecessarily.

The refreshed token isn't sticking across requests — what's wrong?

You refreshed in the loader but did not return the Set-Cookie header from commitSession. Session mutations in Remix only persist when you commit and send the cookie. Capture the result of commitSession(session) after rotating and attach it to the loader’s response headers, as shown above.

Can two concurrent loaders both refresh and cause a token race?

Yes — if a route fans out to parallel loaders that each detect expiry, both may call the refresh endpoint and the rotated tokens can clobber each other. Mitigate by refreshing in a single root/parent loader (or a shared server-side guard), or by storing sessions in a datastore with a short lock around rotation rather than relying solely on the stateless cookie.