The OAuth 2.0 Implicit flow was designed for browser-based applications that couldn't safely store a client secret. It worked by returning tokens directly in the URL fragment after authorization, bypassing the code exchange entirely. It was deprecated in OAuth 2.0 Security Best Current Practice (RFC 9700) in 2020, and for good reason: tokens in URL fragments appear in browser history, in server access logs when the fragment is accidentally included in navigation, and in Referer headers sent to third-party scripts on your callback page. PKCE (Proof Key for Code Exchange) solves the original problem the Implicit flow was addressing — public clients without client secrets — without any of those leakage vectors.
What's wrong with the Implicit flow
The Implicit flow returns tokens in the URL fragment: https://app.example.com/callback#access_token=eyJhb...&token_type=bearer. The fragment is technically not sent to the server, but:
- Browser history: The full URL including the fragment is saved in the user's browser history. Anyone with access to the device can extract the token.
- Referrer header: If any resource on your callback page (analytics script, font loader, tracking pixel) is loaded from a third-party domain, the browser sends a Referer header containing the full URL — including the fragment in some browser/server combinations.
- JavaScript access: The fragment is accessible to any JavaScript running on the page. XSS immediately yields the access token.
- No refresh tokens: The Implicit flow doesn't support refresh tokens. Short-lived tokens mean frequent re-authentication or a silent authentication iframe hack that itself has security implications.
The code interception problem PKCE solves
The Authorization Code flow improves on the Implicit flow by returning a code (not a token) in the redirect. But for public clients — SPAs and mobile apps — there's still a problem: if an attacker intercepts the authorization code (via a malicious app on mobile that registers the same redirect URI scheme, or via a browser extension), they can exchange it for tokens. Without a client secret, there's nothing to stop them.
PKCE solves this by generating a cryptographically random secret (the code verifier) at the start of the flow and binding the authorization code to its hash (the code challenge). Even if an attacker intercepts the code, they don't have the code verifier, so they can't exchange it:
import crypto from 'crypto';
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
// Code verifier: 43-128 characters, URL-safe base64 from 32 random bytes
// 32 bytes * (4/3) base64 encoding ≈ 43 characters — at the minimum
const codeVerifier = crypto.randomBytes(32)
.toString('base64url'); // base64url = URL-safe, no padding
// Code challenge: S256 method = BASE64URL(SHA256(codeVerifier))
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// Usage in authorization request:
const { codeVerifier, codeChallenge } = generatePkce();
sessionStorage.setItem('pkce_code_verifier', codeVerifier); // stored for callback
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', state);
window.location.href = authUrl.toString();
The entropy requirement
The code verifier must have at least 256 bits of entropy. RFC 7636 specifies the verifier must be between 43 and 128 characters from the unreserved characters set. Using 32 random bytes encoded as base64url gives exactly 43 characters and approximately 256 bits of entropy — the recommended minimum. Do not use shorter values. A 16-character verifier has only 128 bits of entropy, which is below acceptable for modern threat models.
Math.random() to generate PKCE verifiers or state values. Math.random() is not cryptographically secure — its output can be predicted from a small number of observed values. Always use crypto.randomBytes() in Node.js, window.crypto.getRandomValues() in browsers, or your platform's equivalent CSPRNG.
Token storage in SPAs
PKCE solves the code exchange problem. Token storage is a separate problem that PKCE doesn't address. After the code exchange, you have access and refresh tokens. Where do they go?
- Memory only (JavaScript variable): Lost on page refresh. Requires silent re-authentication on every page load. Acceptable for high-security applications, poor UX for general applications.
- sessionStorage: Persists across page loads within the same tab. Accessible to XSS. Cleared when the tab closes.
- localStorage: Persists across sessions. Accessible to XSS. The most dangerous option for sensitive tokens.
- HttpOnly cookie via a backend-for-frontend: The correct choice for most applications. The SPA calls your BFF; the BFF exchanges codes and stores tokens in HttpOnly cookies. The SPA never has direct token access.
// Backend-for-frontend pattern: SPA calls your server to complete the OAuth exchange
// Your server stores tokens in HttpOnly cookies, returns only session state to the SPA
// POST /auth/callback (your BFF endpoint, called by the SPA)
export async function bffCallback(req: Request, res: Response) {
const { code, state } = req.body;
// Verify state
const codeVerifier = req.session.pkceCodeVerifier;
if (!codeVerifier) return res.status(400).json({ error: 'No code verifier in session' });
// Exchange code — happens server-side, not in the browser
const tokens = await exchangeCode(code, codeVerifier, REDIRECT_URI);
// Store refresh token in HttpOnly cookie
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth',
maxAge: 30 * 86400 * 1000,
});
// Return access token in response body — SPA stores in memory
res.json({
accessToken: tokens.access_token,
expiresIn: tokens.expires_in,
user: extractUserFromIdToken(tokens.id_token),
});
}
The BFF pattern adds server infrastructure but eliminates the XSS token theft risk entirely. For security-sensitive applications, this is the recommended architecture in the OAuth Security BCP and the OIDC for browser-based applications specification.