Level 3 · 30 min
OAuth 2.0 + PKCE: Auth Done Right
OAuth 2.0 is a delegation framework: a user lets a client app access a protected resource without sharing their password. Done right, it''s the strongest authentication pattern available to most teams. Done wrong, you get account takeovers, leaked tokens, and audit findings. Authorization Code + PKCE is the only flow you should be using in 2026 for new clients.
The Authorization Code Flow
Steps: (1) the client redirects the browser to the authorization server (e.g., accounts.google.com) with response_type=code, client_id, redirect_uri, scope, state. (2) The user authenticates and consents at the AS. (3) The AS redirects the browser back to redirect_uri with ?code=XYZ&state=.... (4) The client''s server exchanges code for tokens at the AS''s /token endpoint, sending the code, client_id, client_secret (for confidential clients), and redirect_uri. (5) The AS returns access_token, refresh_token, id_token. The deprecated flows: Implicit (response_type=token — token comes in URL fragment, easy to leak) and Resource Owner Password Credentials (client sends username/password directly to AS — defeats the entire point of OAuth). Both are removed in OAuth 2.1.
PKCE: The Stateless Defense for Public Clients
A confidential client (web server backend) holds a client_secret that proves identity at the token exchange. A public client (mobile app, SPA) can not — anything shipped to the browser/device is reverse-engineerable. Without PKCE, a public client is vulnerable to authorization code interception: a malicious app on the same device registers the same custom URL scheme, intercepts the redirect with the auth code, and exchanges it. PKCE (Proof Key for Code Exchange) fixes this: (1) client generates a random 43-128 char code_verifier, computes code_challenge = base64url(sha256(code_verifier)), and sends code_challenge + code_challenge_method=S256 with the auth request. (2) AS stores the challenge alongside the issued code. (3) Client exchanges code + the original code_verifier. (4) AS verifies sha256(verifier) == challenge — only the original client knows the verifier, so an interceptor with just the code can not redeem it. PKCE is now mandatory for public clients (RFC 7636) and recommended for all clients (OAuth 2.1).
state, nonce, redirect_uri, and Token Storage
state — a random per-request value the client sends to /authorize and verifies on return. Defeats CSRF on the redirect step (an attacker can not initiate an OAuth flow that lands at the victim''s redirect_uri with the attacker''s code, because state would not match). nonce (OIDC) — random value sent in the auth request, returned in the id_token claim, prevents replay. redirect_uri must be registered exact-match at the AS — open-redirect / wildcard / prefix-match registrations have caused many account takeovers (attacker gets the AS to redirect to evil.com/callback, harvests the code). Token storage: SPAs should use HttpOnly Secure SameSite=Lax cookies via a BFF, not localStorage. Mobile apps use the OS Keychain (iOS) / Keystore (Android). Refresh tokens MUST be one-time-use with rotation detection — reuse signals a compromise. The BCP 212 (current best practice) document is the OAuth security checklist.
Code example
// SPA — generate PKCE pair
const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
const codeChallenge = base64UrlEncode(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier))
);
sessionStorage.setItem("pkce_verifier", codeVerifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem("oauth_state", state);
// Redirect to AS
location.href = `https://auth.example.com/authorize?` +
`response_type=code` +
`&client_id=${CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&scope=openid%20profile` +
`&state=${state}` +
`&code_challenge=${codeChallenge}` +
`&code_challenge_method=S256`;
// Callback handler — verify state, exchange code+verifier for tokens
const params = new URLSearchParams(location.search);
if (params.get("state") !== sessionStorage.getItem("oauth_state")) {
throw new Error("state mismatch — possible CSRF");
}
const tokens = await fetch("https://auth.example.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: params.get("code"),
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: sessionStorage.getItem("pkce_verifier")
})
}).then(r => r.json());