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_supportedin the IdP’s/.well-known/openid-configurationdiscovery document. It must listS256. - The IdP’s JWKS URI for verifying ID Token signatures, and the expected
issuerandaudiencevalues. - 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.
SameSiteandSecurecookie semantics depend on it. - A token verification dependency:
oauth4webapioropenid-clientfor the flow, andjosefor 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:
- 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
HttpOnlycookie or server-side session. The browser never sees a token. This is the recommended default. - 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). Useoauth4webapi, which is Web-Crypto-native. - 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.
Phase 4 — Storing Tokens: HttpOnly Cookie vs Server Session
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, anddocument.cookie. You should see only the opaquesid(or nothing, sinceHttpOnlycookies are invisible todocument.cookie). Any JWT visible to JS is a failure. - State enforcement. Replay a
/callback?code=...&state=WRONGrequest withcurl. 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(oralg: 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 anHttpOnlycookie keeps it out of reach of injected scripts. - Integrity of the callback.
stateis your sole defense against login CSRF;nonceis 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.
Related
- Implementing Authorization Code Flow with PKCE — the cryptographic flow these handlers run end to end.
- Secure Token Refresh and Rotation Patterns — rotating refresh tokens during silent refresh.
- Configuring Identity Providers for OIDC — discovery, JWKS, and redirect URI registration.
- Integrating OIDC with Next.js App Router — Route Handlers, middleware, and server-side sessions.
- Protecting FastAPI Routes with OIDC Bearer Tokens — validating access tokens at a Python resource server.