$ POST /token grant_type=authorization_code code_verifier=dBjftJeZ... {"token_type":"Bearer","id_token":"eyJhbGciOi...","expires_in":3600} ✓ signature verified; iss, aud, exp and nonce match
OpenID Connect is simple enough that a working login takes an afternoon, and subtle enough that a vulnerable one takes the same afternoon.
This guide walks through the authorization code flow with PKCE, shows where each protection belongs, and lists the mistakes that appear again and again in code review.
The relevant documents are RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), OpenID Connect Core 1.0, RFC 8252 (OAuth for native apps) and RFC 9700, the OAuth 2.0 Security Best Current Practice. If you only read one, read RFC 9700.
For vocabulary, see the glossary entries on OIDC, OAuth and PKCE.
The flow in one picture
- Your app generates a random
state, a randomnonceand a PKCEcode_verifier, and stores them for this attempt. - It redirects the browser to the authorization endpoint with the
code_challenge(a hash of the verifier). - The user authenticates. The provider redirects back to your registered redirect URI with a
codeand yourstate. - Your app checks
state, then calls the token endpoint with thecodeand the originalcode_verifier. - The provider returns an ID token, an access token and possibly a refresh token.
- Your app validates the ID token, then creates its own session.
PKCE was originally designed for apps that cannot keep a secret, such as mobile and single-page applications.
Current guidance in RFC 9700 recommends it for all clients using the code flow, including those with a client secret, because it also defends against authorization code injection.
Watch out
Test the failure paths, not only the happy one: a wrong state, an expired token, a wrong audience and a replayed code.
Step 1: Build the authorization request
import crypto from "node:crypto";
const b64url = (buf) =>
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
function startLogin(session) {
const state = b64url(crypto.randomBytes(32));
const nonce = b64url(crypto.randomBytes(32));
const verifier = b64url(crypto.randomBytes(64)); // 43-128 chars allowed
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
session.oidc = { state, nonce, verifier };
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "openid profile email",
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
});
return `${AUTHORIZE_URL}?${params}`;
}
Points that matter:
- Use S256, never
plain. Theplainmethod sends the verifier itself as the challenge and offers almost no protection. - Generate values with a cryptographically secure random source, not a general-purpose random function.
- Store the values server-side or in a short-lived, signed, same-site cookie, bound to this browser session.
- Always include the
openidscope. Without it you are doing plain OAuth and receive no ID token.
Step 2: Handle the callback
async function handleCallback(query, session) {
const saved = session.oidc;
delete session.oidc; // one use only
if (!saved || query.state !== saved.state) throw new Error("bad state");
if (query.error) throw new Error(query.error);
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: query.code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: saved.verifier,
}),
});
const tokens = await res.json();
const claims = await validateIdToken(tokens.id_token, saved.nonce);
return claims;
}
The state check defends against cross-site request forgery on your callback: it proves the response belongs to a request this browser started. Deleting the saved values makes each attempt single-use.
Redirect URI: exact match, nothing clever
The provider must compare the redirect URI by exact string against a registered list. Do the same on your side:
- Register full URLs, including path. Never register a pattern, a wildcard or a bare domain.
- Do not accept a
redirect_urithat your own server reads from the query string and then uses. - Do not leave
http://localhostentries in production configuration. - For native apps, follow RFC 8252: use the system browser, not an embedded web view, and a claimed HTTPS link or loopback redirect.
Loose redirect matching is one of the most common ways codes and tokens leak.
Step 3: Validate the ID token
The ID token is a signed JWT (RFC 7519, signed per RFC 7515). Never just decode it and trust it. Check, in this order:
- Signature, using the provider's public key from its JWKS (RFC 7517), choosing the key by the
kidheader. Rejectalg: noneand any algorithm you did not expect; pin the allowed algorithm list in your code. issequals the issuer exactly as configured, character for character.audcontains yourclient_id. If it has several audiences, also checkazp.expis in the future andiatis not unreasonably far in the past, allowing a small clock skew of a minute or two.nonceequals the one you stored. This ties the token to this login attempt and stops replay.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL(JWKS_URI));
async function validateIdToken(idToken, expectedNonce) {
const { payload } = await jwtVerify(idToken, jwks, {
issuer: ISSUER,
audience: CLIENT_ID,
algorithms: ["RS256"],
clockTolerance: 60,
});
if (payload.nonce !== expectedNonce) throw new Error("bad nonce");
return payload;
}
A maintained library does the signature work better than hand-written code. You still own the configuration: issuer, audience, algorithms and nonce.
JWKS caching and key rotation
Providers rotate signing keys. Fetching the key set on every login is slow and fragile; caching it forever breaks the day a key changes.
- Cache the JWKS in memory and honour the HTTP cache headers.
- If a token arrives with an unknown
kid, refetch once, then fail. Rate limit that refetch so a flood of bad tokens cannot turn into a flood of requests to the provider. - Keep the last good key set if a refresh fails, and alert.
- Fetch the JWKS URL from the provider's discovery document, not a hard-coded guess.
Tokens after login
- Do not use the ID token as an API credential. It tells your app who signed in. Access tokens are for calling APIs.
- Keep tokens out of local storage in browser apps where you can; a server-side session with an HttpOnly, Secure, SameSite cookie is safer against script injection.
- Refresh tokens should be rotated: each use returns a new refresh token and invalidates the old one, so a stolen copy that is used twice is detectable. RFC 9700 recommends sender-constrained tokens or rotation for public clients.
- Revoke on logout where the provider supports it (RFC 7009), and expire your own session on a sensible schedule.
Common mistakes
- Skipping
statebecause "we use PKCE". They protect against different attacks. Use both. - Verifying the signature but not
audoriss, which lets a token minted for another app log in to yours. - Accepting tokens signed with
HS256using the public key as the secret, because the algorithm list was not pinned. - Logging full tokens or authorization codes.
- Treating the
emailclaim as a permanent identifier. Usesubtogether withissas the stable key. - Hand-rolling the protocol where a vetted library exists.
- Testing only the success path. Test a wrong state, an expired token, a wrong audience and a replayed code.
AuthMantra supports OpenID Connect with the authorization code flow and PKCE, with SDKs for JavaScript, Python and Android; the validation duties described above remain yours as the relying party whichever provider you use.
What to do this week
- Review your login code against the five ID token checks.
- Confirm
S256and exact redirect URI matching in every client. - Write four negative tests: bad state, wrong audience, expired token, replayed code.
- Check how your app behaves when the provider rotates a key.
- Replace any use of ID tokens as API credentials.
For the wider context, see what SSO is, and how SAML and OIDC differ.