Topics
v6.0.0
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.

FlowToken exposureStatus
Implicit (response_type=token)Access token in URL fragmentDeprecated (OAuth 2.1)
Auth code + PKCEShort-lived code in query; exchanged with verifierStandard
Resource owner passwordUser password in appDeprecated
BFF exchangeServer holds secret; HttpOnly sessionBest 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=xyz

Public 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

MistakeConsequence
No state checkLogin CSRF
Weak verifier (Math.random)Brute-force exchange
Token in URL fragmentHistory and Referer leak
Open / dynamic redirect_uriCode theft via redirect hijack
Tokens in localStorageXSS exfiltration
Verifier not cleared after exchangeExtended theft window
Echo error_description unsanitizedReflected 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 session

Practical 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

Last updated on