Integrating OIDC With Web Frameworks

Wiring an identity provider into a web framework is where most OIDC deployments quietly become insecure: tokens leak into localStorage, the callback skips state validation, and the redirect URI is registered with a wildcard. This guide, part of the OIDC & OAuth 2.0 Implementation reference, is framework-agnostic by design. It establishes where the authorization code flow must run, how tokens should be held, and what the server handler has to validate before it trusts a callback — concepts that then map cleanly onto Next.js, Remix, or a Python resource server.

The core decision is architectural, not framework-specific. OIDC layers identity on top of OAuth 2.0 (RFC 6749), and the authorization code flow with PKCE (RFC 7636) is the only acceptable flow for browser-facing clients. The question every framework forces you to answer is: which trusted component holds the code_verifier, performs the token exchange, and stores the resulting tokens? Get that boundary right and the rest is plumbing.

Prerequisites

Before you write a single route handler, confirm the following are in place:

  • A registered confidential or public client at your IdP with an exact-match redirect_uri (e.g. https://app.example.com/auth/callback). No wildcards, no trailing-slash ambiguity, HTTPS only.
  • PKCE support verified by inspecting code_challenge_methods_supported in the IdP’s /.well-known/openid-configuration discovery document. It must list S256.
  • The IdP’s JWKS URI for verifying ID Token signatures, and the expected issuer and audience values.
  • A server-side secret for sealing session cookies (32+ bytes of entropy), distinct from the OAuth client_secret.
  • An HTTPS origin in every non-local environment. SameSite and Secure cookie semantics depend on it.
  • A token verification dependency: oauth4webapi or openid-client for the flow, and jose for signature checks.

This guide assumes you have already implemented the authorization code flow with PKCE at least once; here we focus on where that flow lives inside a framework.

Problem Framing: Where Does the Flow Run?

A browser cannot be trusted to hold long-lived credentials. Any token reachable by JavaScript is reachable by an XSS payload. That single constraint dictates the topology. You have three viable placements for the OIDC machinery:

  1. Server-side handler (BFF) — The framework’s server (Next.js Route Handler, Remix loader/action, Express route) runs the code exchange and stores tokens in an HttpOnly cookie or server-side session. The browser never sees a token. This is the recommended default.
  2. Edge handler — The same logic runs in an edge runtime (Cloudflare Workers, Vercel Edge). Identical security model to the BFF, with lower latency and a more constrained runtime (Web Crypto only, no Node crypto). Use oauth4webapi, which is Web-Crypto-native.
  3. Pure SPA (public client) — The browser performs PKCE and holds tokens in memory. Acceptable only when no server exists, and even then tokens must live in memory, never persistent storage.

The BFF pattern (Backend-for-Frontend) collapses placements 1 and 2 into a single principle: a server-side component owns the OAuth lifecycle and exposes only a session cookie to the browser. The browser calls your API; your server attaches the access token. This neutralizes token exfiltration via XSS because there is no token in the document.

sequenceDiagram
    participant B as Browser
    participant F as Framework Server\nBFF
    participant I as IdP\nAuth Server
    B->>F: GET /login
    F->>F: Generate verifier\nstate, nonce
    F->>B: 302 to IdP\n+ set temp cookie
    B->>I: Authorization request\ncode_challenge=S256
    I->>B: User authenticates
    B->>F: GET /callback?code&state
    F->>F: Validate state\n+ nonce
    F->>I: POST /token\ncode + verifier
    I->>F: id_token + access\n+ refresh
    F->>F: Verify id_token\nRS256 via JWKS
    F->>B: 302 to app\n+ HttpOnly session

The diagram makes the trust boundary explicit: every credential-bearing step happens on the Framework Server lane. The browser only ever carries a redirect, a one-time state, and finally an opaque session cookie.

Step-by-Step Implementation

The following phases are framework-agnostic. Each maps to a request handler — a Route Handler in Next.js, a loader/action in Remix, a controller in Express, or an FastAPI endpoint. The library is oauth4webapi, which runs in Node and on the edge.

Phase 1 — Discovery and Client Configuration

Load the IdP’s metadata once at boot and cache it. Never hardcode endpoint URLs; the discovery document is the source of truth and survives IdP migrations.

import * as oauth from "oauth4webapi";

const issuer = new URL(process.env.OIDC_ISSUER!); // e.g. https://idp.example.com
const as = await oauth
  .discoveryRequest(issuer, { algorithm: "oidc" })
  .then((res) => oauth.processDiscoveryResponse(issuer, res));

const client: oauth.Client = {
  client_id: process.env.OIDC_CLIENT_ID!,
  token_endpoint_auth_method: "client_secret_basic",
};
const clientAuth = oauth.ClientSecretBasic(process.env.OIDC_CLIENT_SECRET!);

// Enforce a strict signature algorithm allowlist — never alg:none, never HS256 for OIDC.
const ALLOWED_ID_TOKEN_ALG = "RS256"; // or "ES256" if the IdP signs with EC keys

Phase 2 — The /login Handler

The login handler generates the PKCE code_verifier, the CSRF state, and the replay-defeating nonce, then stashes all three in a short-lived, sealed cookie before redirecting to the IdP. These values must survive the round trip without being readable or forgeable by the browser.

export async function handleLogin(): Promise<Response> {
  const code_verifier = oauth.generateRandomCodeVerifier();
  const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier);
  const state = oauth.generateRandomState();
  const nonce = oauth.generateRandomNonce();

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

  // Persist verifier/state/nonce in a sealed, HttpOnly, SameSite=Lax cookie.
  // SameSite=Lax is REQUIRED so the cookie survives the top-level GET redirect back.
  const txn = await sealTransaction({ code_verifier, state, nonce });
  return new Response(null, {
    status: 302,
    headers: {
      Location: authUrl.toString(),
      "Set-Cookie": `oidc_txn=${txn}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
    },
  });
}

SameSite=Lax is deliberate: the IdP redirects the user back with a top-level GET, and a Strict cookie would not be sent on that cross-site navigation, breaking the flow. This is the same constraint described in configuring secure cookie flags in production.

Phase 3 — The /callback Handler

This is the security-critical handler. It must validate state against the sealed transaction (CSRF defense per the OAuth security BCP, RFC 9700), exchange the code with the original code_verifier, then verify the ID Token signature, iss, aud, exp, and nonce.

export async function handleCallback(request: Request, txnCookie: string): Promise<Response> {
  const { code_verifier, state, nonce } = await unsealTransaction(txnCookie);
  const url = new URL(request.url);

  // 1. CSRF: the state in the callback MUST equal the one we issued at /login.
  const params = oauth.validateAuthResponse(as, client, url, state);

  // 2. Exchange the code, binding the PKCE verifier (RFC 7636).
  const tokenResponse = await oauth.authorizationCodeGrantRequest(
    as, client, clientAuth, params,
    process.env.OIDC_REDIRECT_URI!, code_verifier,
  );

  // 3. Verify the ID Token: signature via JWKS, nonce, and an explicit alg allowlist.
  const result = await oauth.processAuthorizationCodeResponse(as, client, tokenResponse, {
    expectedNonce: nonce,
    requireIdToken: true,
  });

  const claims = oauth.getValidatedIdTokenClaims(result)!;
  if (!claims.sub) throw new Error("missing subject");

  // 4. Establish the application session. Tokens go server-side, never to the browser.
  const session = await createSession({
    sub: claims.sub,
    access_token: result.access_token,
    refresh_token: result.refresh_token,
    expires_at: Date.now() + (result.expires_in ?? 300) * 1000,
  });

  return new Response(null, {
    status: 302,
    headers: {
      Location: "/",
      // Clear the transaction cookie; set the durable session cookie.
      "Set-Cookie": [
        `oidc_txn=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
        `sid=${session.id}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400`,
      ].join(", "),
    },
  });
}

Note that oauth4webapi enforces the configured signing algorithm internally; if your IdP signs ID Tokens with ES256, supply that — but never permit alg: none or HS256, both of which enable algorithm-confusion forgery against asymmetric verification.

There are two durable placements, and the choice drives your revocation story:

Strategy Where tokens live Revocation Best for
Encrypted cookie session Sealed inside an HttpOnly cookie sent to the browser Hard — cookie is self-contained until expiry Stateless edge deploys, small token sets
Server-side session Redis/DB keyed by an opaque sid cookie Easy — delete the server record Apps needing instant logout and refresh rotation

For anything requiring immediate logout, prefer the server-side session: the browser holds only an opaque sid, and revocation is a single delete. This pairs naturally with secure token refresh and rotation patterns, where each refresh swaps the stored token server-side.

Phase 5 — Silent Refresh

Access tokens are short-lived (5–15 minutes). The server detects an expired (or near-expired) access token on an incoming request and refreshes it transparently using the stored refresh token, before proxying the call to the resource server.

export async function ensureFreshAccessToken(session: Session): Promise<string> {
  if (Date.now() < session.expires_at - 30_000) return session.access_token;

  const res = await oauth.refreshTokenGrantRequest(as, client, clientAuth, session.refresh_token!);
  const refreshed = await oauth.processRefreshTokenResponse(as, client, res);

  // Rotate: persist the NEW refresh token server-side; the old one is now invalid.
  await updateSession(session.id, {
    access_token: refreshed.access_token,
    refresh_token: refreshed.refresh_token ?? session.refresh_token,
    expires_at: Date.now() + (refreshed.expires_in ?? 300) * 1000,
  });
  return refreshed.access_token!;
}

Because this runs server-side, the browser is never aware a refresh occurred — no hidden iframes, no prompt=none round trips, no token in the page.

Validation & Testing

Verify the trust boundary holds before shipping:

  • No token in the document. Open devtools, run localStorage, sessionStorage, and document.cookie. You should see only the opaque sid (or nothing, since HttpOnly cookies are invisible to document.cookie). Any JWT visible to JS is a failure.
  • State enforcement. Replay a /callback?code=...&state=WRONG request with curl. It must return a 4xx, not a session.
  • Redirect URI rejection. Submit an authorization request with an unregistered redirect_uri. The IdP must reject it; do not rely on your app to catch this.
  • Signature allowlist. Feed a token signed with HS256 (or alg: none) into your verifier in a test. It must throw.
# State must be rejected when it doesn't match the issued transaction cookie.
curl -i "https://app.example.com/auth/callback?code=abc&state=forged" \
  --cookie "oidc_txn=<sealed-legit-txn>"
# Expect: HTTP/1.1 400 Bad Request

Common Misconfigurations

Misconfiguration Symptom Fix
Token stored in localStorage/sessionStorage Token readable by any script; XSS = full account takeover Move the entire flow server-side; store tokens in an HttpOnly cookie or server session
Missing or unvalidated state Login CSRF; attacker fixes a victim’s session to the attacker’s account Generate state at /login, seal it, and compare exactly at /callback (RFC 9700)
Missing nonce validation ID Token replay/injection accepted Send nonce in the auth request; assert id_token.nonce matches at exchange
redirect_uri registered with a wildcard Open-redirect token theft Register exact URIs only; reject any mismatch at the IdP
SameSite=Strict on the transaction cookie Callback loses its cookie; flow silently fails Use SameSite=Lax on the transaction and session cookies
HS256 or alg: none accepted by verifier Algorithm-confusion forgery of ID Tokens Pin RS256/ES256 explicitly; reject everything else

Security Implications

The framework integration is the chokepoint for the entire OIDC trust model. Three properties must hold:

  • Confidentiality of the verifier. The code_verifier (RFC 7636) defeats authorization-code interception only if an attacker cannot read it. Sealing it in an HttpOnly cookie keeps it out of reach of injected scripts.
  • Integrity of the callback. state is your sole defense against login CSRF; nonce is your sole defense against ID Token replay. Both are mandated by the OAuth 2.0 Security Best Current Practice (RFC 9700) and OpenID Connect Core. Validate both with exact, timing-safe comparison.
  • Isolation of tokens. Keeping access and refresh tokens server-side means an XSS bug cannot exfiltrate them. This is the entire justification for the BFF pattern and aligns with OWASP ASVS session-management controls.

When tokens must reach the browser (a true SPA with no backend), constrain them to memory and pair the design with the defenses in securing localStorage vs HttpOnly cookies. For any framework with a server runtime, there is no reason to expose them.

These concepts ground three concrete walkthroughs. In Next.js, the flow lives in Route Handlers and middleware — see integrating OIDC with Next.js App Router. In Remix, it lives in loaders and actions with sealed cookie sessions — see adding OIDC to Remix with secure sessions. And when your service is purely a resource server that only validates inbound access tokens, see protecting FastAPI routes with OIDC bearer tokens. Whichever you build, start from a correctly configured identity provider.