On this page · sections
OAuth PKCE for SPAs
SPAs are public clients — they cannot hold client_secret in the bundle. Authorization code flow + PKCE replaced the deprecated implicit flow for browser apps.
| Flow | Token exposure | Status |
|---|---|---|
Implicit (response_type=token) | Access token in URL fragment | Deprecated (OAuth 2.1) |
| Auth code + PKCE | Short-lived code in query; exchanged with verifier | Standard |
| Resource owner password | User password in app | Deprecated |
| BFF exchange | Server holds secret; HttpOnly session | Best for refresh tokens |
Why Implicit Flow Is Deprecated
Definition
Implicit flow returns access token directly in the URL fragment (#access_token=...). Fragment is not sent to server on navigation — but JavaScript, browser history, analytics, and Referer leaks still expose it.
'use strict';
// DEPRECATED — do not implement
// https://app.example.com/callback#access_token=eyJ...&token_type=Bearer
// Token in fragment:
// - Visible in browser history
// - Readable by any XSS on callback page
// - Leaked via Referer if page loads third-party resources
// REPLACED BY — authorization code in query + PKCE exchange
// https://app.example.com/callback?code=AUTH_CODE&state=xyzPublic client — SPA, mobile app, desktop app. Cannot embed client_secret — extractable from bundle or binary.
Authorization code + PKCE — short-lived code in query string (not usable without verifier). Exchanged at token endpoint with code_verifier proof of possession.
Interview: Fragment does not hide token from client — history, XSS, and Referer still leak it.
Authorization Code Flow with PKCE
Definition
1. Generate code_verifier + code_challenge (S256)
2. Redirect to /authorize with PKCE params + state
3. User authenticates at IdP
4. Callback with code + state — validate state anti-CSRF
5. POST /token with code + code_verifier (+ redirect_uri)
6. Receive access token — never in URL fragment'use client';
const AUTH_URL = 'https://idp.example.com/oauth/authorize';
const CLIENT_ID = 'spa-client-id';
const REDIRECT_URI = 'https://app.example.com/auth/callback';
const SCOPES = 'openid profile email';
function base64UrlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
bytes.forEach((b) => { binary += String.fromCharCode(b); });
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function sha256(input: string): Promise<ArrayBuffer> {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
}
export async function startLogin() {
const verifier = base64UrlEncode(
crypto.getRandomValues(new Uint8Array(32)).buffer,
);
const challenge = base64UrlEncode(await sha256(verifier));
const state = base64UrlEncode(
crypto.getRandomValues(new Uint8Array(16)).buffer,
);
sessionStorage.setItem('oauth_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `${AUTH_URL}?${params.toString()}`;
}redirect_uri — exact match to registered value. Wildcard or dynamic redirect = open redirect = code theft.
BFF pattern — browser sends code to same-origin /api/auth/callback; server exchanges with optional confidential client. Refresh token never in SPA JS if HttpOnly cookie session.
Interview: state prevents login CSRF — attacker binds victim to attacker’s account without it.
code_verifier and code_challenge
Definition
code_verifier — cryptographically random string (43–128 chars), stored client-side until token exchange. Never sent on authorize request.
code_challenge — one-way transform sent on authorize:
BASE64URL(SHA-256(ASCII(code_verifier)))'use strict';
function base64UrlEncode(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function createPkcePair() {
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64UrlEncode(verifierBytes.buffer);
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier),
);
const challenge = base64UrlEncode(digest);
return { verifier, challenge, method: 'S256' };
}
// Authorize: code_challenge + code_challenge_method=S256
// Token: code_verifier=verifier (plaintext — server compares hash)IdP stores challenge keyed to authorization code. Token exchange sends verifier plaintext — IdP hashes and compares. Stolen code without verifier cannot be exchanged.
Use crypto.getRandomValues, not Math.random(). Fresh pair every authorize attempt.
Interview: S256 is required by OAuth 2.1 — plain method (challenge equals verifier) is deprecated.
Common PKCE Implementation Mistakes
Definition
| Mistake | Consequence |
|---|---|
| No state check | Login CSRF |
Weak verifier (Math.random) | Brute-force exchange |
| Token in URL fragment | History and Referer leak |
Open / dynamic redirect_uri | Code theft via redirect hijack |
| Tokens in localStorage | XSS exfiltration |
| Verifier not cleared after exchange | Extended theft window |
Echo error_description unsanitized | Reflected XSS on callback page |
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export function OAuthCallback() {
const params = useSearchParams();
const router = useRouter();
useEffect(() => {
async function handleCallback() {
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (error) {
router.replace('/login?failed=1');
return;
}
const savedState = sessionStorage.getItem('oauth_state');
const verifier = sessionStorage.getItem('oauth_verifier');
if (!code || !state || state !== savedState || !verifier) {
router.replace('/login?failed=1');
return;
}
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_verifier');
const res = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, verifier }),
credentials: 'include',
});
if (!res.ok) {
router.replace('/login?failed=1');
return;
}
router.replace('/dashboard');
}
handleCallback();
}, [params, router]);
return <p>Signing you in…</p>;
}Never log code or verifier. Redirect preferred over popup.
Interview: PKCE is necessary but not sufficient — state and redirect URI exact match matter equally.
Post-Login Token Handling
Definition
BFF pattern (best):
- Browser sends authorization code to same-origin
/api/auth/token - Server exchanges with IdP
- Server sets HttpOnly session cookie
- Browser never sees refresh token
'use client';
let accessToken: string | null = null;
export async function exchangeCodeViaBff(code: string, verifier: string) {
const res = await fetch('/api/auth/token', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, verifier }),
});
if (!res.ok) throw new Error('Token exchange failed');
const data = await res.json();
accessToken = data.accessToken;
return data;
}
export async function authFetch(input: RequestInfo, init: RequestInit = {}) {
if (!accessToken) {
const refresh = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!refresh.ok) throw new Error('Unauthorized');
const data = await refresh.json();
accessToken = data.accessToken;
}
return fetch(input, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${accessToken}`,
},
});
}Avoid: refresh token in localStorage or sessionStorage — XSS reads both.
Refresh token rotation — IdP issues new refresh on each use. Detect reuse (revoke family) as breach signal.
Logout: revoke at IdP (end_session_endpoint), clear HttpOnly cookies server-side, null memory access token.
Real-world example
PWA offline auth — use BFF with HttpOnly refresh cookie scoped to same-origin, or service worker proxy to same-origin refresh endpoint — not browser localStorage for refresh tokens.
Interview: Access in memory; refresh HttpOnly; never localStorage — align with XSS defenses from JWT storage topic.
Senior Interview Summary
Mental model
Public client → No client_secret in bundle; PKCE mandatory
Implicit flow → Token in URL fragment; deprecated
Auth code + PKCE → code in query; verifier on exchange
code_verifier → 43–128 char random; crypto.getRandomValues
code_challenge → BASE64URL(SHA-256(verifier)); method S256
state → Anti-CSRF on login; validate before exchange
BFF → Server exchange; HttpOnly sessionPractical rules
- redirect_uri exact registered match — no wildcards
- Clear verifier from sessionStorage immediately after exchange
- Separate OAuth clients per platform (web vs mobile redirect schemes)
- IdP config must enforce S256; reject plain in production
- OIDC id_token — validate signature,
aud,iss,nonce
What to say in interviews
For PWA offline, we use BFF with HttpOnly refresh cookie, short-lived access in memory, and service worker proxy to same-origin refresh — not localStorage. Refresh token rotation with reuse detection separates senior answers from checklist recitation.
Common traps
- Client secret in SPA bundle is extractable
- Skip state parameter → login CSRF
- Silent refresh via hidden iframe killed by third-party cookie blocking
- PKCE alone is not sufficient without state and redirect URI validation