PKCE Authorization Code Flow for SPAs and Mobile Apps

A single-page application or native mobile app is a public client: it has no way to keep a secret, because anyone can open the bundle and read whatever is inside it. That rules out the classic confidential-client exchange, where a server proves its identity with a client_secret. Proof Key for Code Exchange (PKCE, RFC 7636) fills the gap with a per-request secret that lives only in the running client for the duration of one login. This guide implements the Authorization Code flow with PKCE end to end in TypeScript using the Web Crypto API, from generating the code_verifier through redeeming the authorization code. It is part of the OAuth 2.0 & OpenID Connect Implementation guide within Secure Authentication & Session Architecture. Where the resulting tokens should ultimately live is a separate decision covered in token storage patterns — resolve it before you ship.

Prerequisites

  • A browser SPA (or mobile client) targeting an OIDC-compliant authorization server
  • The provider advertises S256 in code_challenge_methods_supported and supports response_type=code
  • A registered public client with token_endpoint_auth_method set to none and an exact redirect_uri
  • A modern runtime with the Web Crypto API (crypto.subtle) available over HTTPS
  • sessionStorage or an equivalent per-flow store for transient values

Expected Outcomes

  • A fresh, high-entropy code_verifier and its S256 code_challenge generated per login attempt
  • An authorize request carrying code_challenge, state, and nonce, with the verifier persisted
  • A callback handler that validates state and exchanges the code plus verifier for tokens
  • Transient flow values cleared immediately after exchange, tokens handed to a safe store

Step 1: Generate the code_verifier and S256 code_challenge

The code_verifier is a cryptographically random string of 43–128 characters from the unreserved URL character set. The code_challenge is the base64url-encoded SHA-256 digest of that verifier. The Web Crypto API provides both getRandomValues for entropy and subtle.digest for the hash — no third-party library is required in the browser.

// pkce.ts — runs in the browser using Web Crypto
function base64UrlEncode(bytes: ArrayBuffer): string {
  const b = String.fromCharCode(...new Uint8Array(bytes));
  return btoa(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export function createVerifier(): string {
  // 32 random bytes -> 43-char base64url string, within the RFC 7636 range
  const random = new Uint8Array(32);
  crypto.getRandomValues(random);
  return base64UrlEncode(random.buffer);
}

export async function challengeFromVerifier(verifier: string): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return base64UrlEncode(digest); // this is the S256 code_challenge
}

The verifier must be generated fresh for every login attempt. Reusing a verifier across flows, or deriving it from anything predictable, reintroduces the interception risk PKCE exists to remove. Never send the verifier in the authorize request — only its hash travels up front.

Step 2: Build the Authorize Request with State

Persist the code_verifier, state, and nonce in a per-flow store, then redirect the browser to the authorization endpoint with the challenge attached. The state defends the callback against CSRF; the nonce will bind the returned ID token to this login. The sequence below shows where each value is created, stored, and later checked.

PKCE Public-Client Sequence Diagram showing the SPA generating a verifier and challenge, storing the verifier in sessionStorage, redirecting to the authorization server with the challenge and state, receiving a one-time code, and exchanging the code with the verifier for tokens. SPA / Mobile Client public client (no secret) Authorization Server trusted zone store verifier + state in sessionStorage /authorize + code_challenge (S256) + state + nonce 302 redirect with one-time code + state POST /token: code + code_verifier Server recomputes SHA-256(verifier) and compares to the stored challenge
import { createVerifier, challengeFromVerifier } from "./pkce";

export async function startLogin(cfg: {
  authorizeEndpoint: string; clientId: string; redirectUri: string;
}) {
  const verifier = createVerifier();
  const challenge = await challengeFromVerifier(verifier);
  const state = createVerifier().slice(0, 22);   // reuse the CSPRNG helper for entropy
  const nonce = createVerifier().slice(0, 22);

  // Persist per-flow values; cleared after exchange in Step 3
  sessionStorage.setItem("pkce_verifier", verifier);
  sessionStorage.setItem("pkce_state", state);
  sessionStorage.setItem("pkce_nonce", nonce);

  const url = new URL(cfg.authorizeEndpoint);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: cfg.clientId,
    redirect_uri: cfg.redirectUri,
    scope: "openid profile email",
    code_challenge: challenge,
    code_challenge_method: "S256",
    state,
    nonce,
  }).toString();

  window.location.assign(url.toString());
}

Step 3: Handle the Callback and Exchange the Code for Tokens

When the authorization server redirects back, first compare the returned state to the stored value and abort on any mismatch. Then POST the code together with the stored code_verifier to the token endpoint. The server recomputes SHA-256(verifier), compares it to the challenge it saw in Step 2, and only issues tokens if they match.

export async function completeLogin(cfg: {
  tokenEndpoint: string; clientId: string; redirectUri: string;
}) {
  const params = new URLSearchParams(window.location.search);
  const returnedState = params.get("state");
  const storedState = sessionStorage.getItem("pkce_state");
  if (!returnedState || returnedState !== storedState) {
    throw new Error("state mismatch — aborting login");
  }

  const code = params.get("code");
  const verifier = sessionStorage.getItem("pkce_verifier");
  if (!code || !verifier) throw new Error("missing code or verifier");

  const res = await fetch(cfg.tokenEndpoint, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: cfg.redirectUri,
      client_id: cfg.clientId,
      code_verifier: verifier,
    }),
  });
  if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);

  // Clear transient values the moment they are no longer needed
  sessionStorage.removeItem("pkce_verifier");
  sessionStorage.removeItem("pkce_state");
  return res.json(); // { id_token, access_token, refresh_token, ... }
}

Step 4: Store the Resulting Tokens Safely

Do not leave tokens sitting in localStorage, where any injected script can read them. The most robust pattern for a browser client is Backend-for-Frontend: a small backend performs the exchange and hands the SPA a same-site, HTTP-only session cookie, keeping tokens out of JavaScript entirely. Where a pure-SPA design is unavoidable, keep the access token in memory and never persist the refresh token in web storage. The trade-offs, cookie flags, and threat model are detailed in token storage patterns; make that choice deliberately rather than defaulting to whichever line of code is shortest.

Verification

Confirm the challenge is a correct S256 transform of the verifier, and that the exchange carries the verifier. Decode the two values and recompute the hash:

# Given a verifier captured in the browser, recompute the S256 challenge:
VERIFIER="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
printf '%s' "$VERIFIER" \
  | openssl dgst -binary -sha256 \
  | openssl base64 -A \
  | tr '+/' '-_' | tr -d '='
# The output MUST equal the code_challenge sent in the /authorize request.

In the browser, verify the exchange request in the network tab: the POST to the token endpoint must include code_verifier and code_challenge_method must have been S256 on the authorize request. Confirm sessionStorage no longer contains pkce_verifier after a successful login.

Troubleshooting

Symptom Likely cause Fix
Token endpoint returns invalid_grant Verifier does not hash to the challenge, or the code was already used Regenerate a fresh verifier per attempt; ensure the exact stored verifier is sent, and never reuse a code
redirect_uri_mismatch at the token endpoint The redirect_uri in the exchange differs from the one in the authorize request Send byte-for-byte identical redirect_uri values in both requests, matching the registered value
state is null on the callback sessionStorage was cleared by a full reload or a different tab handled the redirect Persist state before redirecting and complete the flow in the same browsing context
Server rejects code_challenge_method Provider does not accept plain, or the app sent plain Always send S256; confirm the provider advertises it in discovery
Tokens work but ID token is untrusted The client never verified the ID token signature Validate the ID token against the provider JWKS before trusting any claim

Common Implementation Mistakes

Frequently Asked Questions

Why does a public client use PKCE instead of a client secret?

A SPA or mobile app cannot keep a secret: anything shipped in the bundle can be extracted with the browser dev tools or by decompiling the app. PKCE replaces the static secret with a per-request proof. The client generates a random code_verifier, sends only its S256 hash in the authorize request, and later presents the raw verifier at the token endpoint. Because the verifier never travels in the redirect channel, an attacker who intercepts the authorization code still cannot redeem it. The secret is ephemeral and unique to one login rather than baked into a distributable.

Where should the code_verifier be stored during the redirect?

Use sessionStorage or an in-memory store scoped to the flow, not localStorage. sessionStorage is per-tab and cleared when the tab closes, which limits the window and blast radius of exposure. On mobile, use the platform secure store (Keychain or Keystore). Whichever you choose, delete the verifier and state the instant the token exchange succeeds so they cannot be replayed if the page is later compromised.

Do I need a backend if I use PKCE in a SPA?

PKCE makes it possible to complete the flow without a backend, but the more secure architecture is Backend-for-Frontend: a small server performs the code exchange, holds the tokens, and issues the browser a same-site, HTTP-only session cookie. That keeps tokens entirely out of reach of JavaScript, which neutralizes token theft via cross-site scripting. Reserve pure-SPA PKCE for cases where a backend genuinely is not an option, and even then keep the access token in memory only.