Skip to content
AuthMantraPraxis
authmantra.com ↗Start free trial

Engineering

Implementing OpenID Connect with PKCE correctly

AuthMantra Team6 min readDeep

Short version: headings, key sentences, diagrams and callouts.
PKCE: the app proves it started the requesthashsent firstcode + verifierAppTokenendpointverifier(secret)challenge =SHA-256(verifier)
token exchangeSimulated output
$ 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

  1. Your app generates a random state, a random nonce and a PKCE code_verifier, and stores them for this attempt.
  2. It redirects the browser to the authorization endpoint with the code_challenge (a hash of the verifier).
  3. The user authenticates. The provider redirects back to your registered redirect URI with a code and your state.
  4. Your app checks state, then calls the token endpoint with the code and the original code_verifier.
  5. The provider returns an ID token, an access token and possibly a refresh token.
  6. 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. The plain method 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 openid scope. 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_uri that your own server reads from the query string and then uses.
  • Do not leave http://localhost entries 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:

  1. Signature, using the provider's public key from its JWKS (RFC 7517), choosing the key by the kid header. Reject alg: none and any algorithm you did not expect; pin the allowed algorithm list in your code.
  2. iss equals the issuer exactly as configured, character for character.
  3. aud contains your client_id. If it has several audiences, also check azp.
  4. exp is in the future and iat is not unreasonably far in the past, allowing a small clock skew of a minute or two.
  5. nonce equals 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 state because "we use PKCE". They protect against different attacks. Use both.
  • Verifying the signature but not aud or iss, which lets a token minted for another app log in to yours.
  • Accepting tokens signed with HS256 using the public key as the secret, because the algorithm list was not pinned.
  • Logging full tokens or authorization codes.
  • Treating the email claim as a permanent identifier. Use sub together with iss as 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 S256 and 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.

Try it

PKCEAuthorization code flowOAuth 2.0SSOOIDCJWT