Why PKCE Is the Default for SPA Authentication in 2024
PKCE replaces the client secret with a per-request cryptographic challenge, making the Authorization Code Flow safe for single-page applications. Here's how it works, a minimal implementation, and the risks it doesn't solve.
27 Sept 2026, 22:25 UTC

The Problem: Secrets Don't Belong in the Browser
Single-page applications have a fundamental constraint: anything shipped to the browser can be inspected, copied, or tampered with. The classic OAuth 2.0 Authorization Code Flow relies on a client_secret exchanged at the token endpoint to prove the client's identity. In a traditional server-rendered app, that secret lives on the backend. In an SPA, there is no backend to hide it—storing the secret in JavaScript bundles or localStorage effectively makes it public.
Without a secret, the authorization code becomes a bearer token. If an attacker intercepts the redirect (via a malicious browser extension, compromised network, or referrer leakage), they can exchange the code for tokens themselves. PKCE (Proof Key for Code Exchange, RFC 7636) solves this by replacing the static secret with a per-request cryptographic challenge that only the legitimate SPA can satisfy.
How PKCE Changes the Flow
PKCE adds three parameters to the standard Authorization Code Flow:
- code_verifier: a high-entropy random string (43–128 characters, URL-safe) generated by the SPA for each authorization request.
- code_challenge: the SHA-256 hash of the verifier, Base64-URL encoded (the
S256method). - code_challenge_method: set to
S256to tell the authorization server which hash algorithm to expect.
The SPA sends code_challenge and code_challenge_method in the initial /authorize request. The authorization server stores the challenge. When the SPA later exchanges the authorization code at the /token endpoint, it must include the original code_verifier. The server hashes the received verifier and compares it to the stored challenge. A mismatch rejects the request—even if the attacker has the authorization code, they cannot produce the verifier.
Worked Example: Generating and Using a PKCE Pair
Below is a minimal, framework-agnostic implementation using the Web Crypto API. Run this in the browser console or a module script served over HTTPS.
// 1. Generate a cryptographically random code_verifier (43 chars, URL-safe)
async function generateCodeVerifier() {
const array = new Uint8Array(32); // 256 bits
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 2. Derive the code_challenge using SHA-256 (S256 method)
async function deriveCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 3. Build the authorize URL
async function buildAuthorizeUrl(config) {
const verifier = await generateCodeVerifier();
const challenge = await deriveCodeChallenge(verifier);
// Store verifier in sessionStorage (cleared on tab close)
sessionStorage.setItem('pkce_verifier', verifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scope,
code_challenge: challenge,
code_challenge_method: 'S256',
state: crypto.randomUUID() // CSRF protection
});
return `${config.authServer}/authorize?${params.toString()}`;
}
// 4. Exchange code for tokens (run on redirect back to your SPA)
async function exchangeCodeForTokens(config, authorizationCode) {
const verifier = sessionStorage.getItem('pkce_verifier');
if (!verifier) throw new Error('Missing PKCE verifier');
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: verifier
});
const response = await fetch(`${config.authServer}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
});
if (!response.ok) {
const err = await response.json();
throw new Error(`Token exchange failed: ${err.error_description || err.error}`);
}
sessionStorage.removeItem('pkce_verifier');
return response.json(); // { access_token, id_token, refresh_token, expires_in }
}
Key implementation notes:
- Use
crypto.getRandomValues, notMath.random(), for the verifier. - Store the verifier in
sessionStorage(notlocalStorage) so it disappears when the tab closes. - Always include a
stateparameter for CSRF protection; PKCE does not replace it. - The token endpoint must accept
client_idwithoutclient_secretfor public clients—confirm your authorization server (Auth0, Keycloak, Azure AD, etc.) is configured for "public" or "SPA" client type.
Where PKCE Falls Short
PKCE prevents authorization code interception, but it does not address token storage or transport risks:
- XSS steals tokens, not codes. If your SPA has an XSS vulnerability, an attacker can read
access_tokenfrom memory orlocalStorage. PKCE only protects the acquisition phase. Mitigation: store tokens in memory (JavaScript variables) and use short-lived access tokens with refresh token rotation. - TLS is non-negotiable. PKCE relies on the secrecy of the verifier during the redirect. Without HTTPS, a network observer sees both the challenge (in the authorize request) and the verifier (in the token request if sent over plain HTTP).
- Redirect URI validation remains critical. An open redirect on your domain combined with a leaked authorization code can still be exploited. Register exact redirect URIs (including path) and avoid wildcards.
Verify Your Implementation
- Open the Network tab in DevTools, start an authentication flow, and inspect the
/authorizerequest. Confirmcode_challengeandcode_challenge_method=S256are present. - Copy the authorization code from the redirect URL, then attempt a token exchange via
curlwithout thecode_verifier. The server must returninvalid_grantorinvalid_request. - Repeat the exchange with the correct verifier; it should succeed.
If both checks pass, PKCE is enforced server-side.
Actionable Takeaway
For any new SPA integrating OAuth 2.0, configure the client as a public client with PKCE required (S256 only). Do not fall back to the Implicit Flow or Resource Owner Password Credentials—both are deprecated in OAuth 2.1. Use a maintained library (oidc-client-ts, @auth0/auth0-spa-js, msal-browser) rather than rolling your own cryptography, but understand the verifier/challenge exchange so you can debug when things break.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.