The OAuth 2.0 Authorization Code Flow
"We added Login with Google" usually means someone pasted a tutorial that skips PKCE, stores tokens in localStorage, and never validates the state parameter. The OAuth 2.0 authorization code flow is the correct pattern for web and mobile apps getting tokens from an identity provider—but only when implemented with PKCE, strict redirect URI matching, and server-side token exchange for confidential clients.
Flow overview
User → App → Auth Server (login + consent)
← Authorization Code (redirect)
App → Auth Server (code + verifier → tokens)
← Access Token + Refresh Token
App → Resource Server (Bearer access_token)
The authorization code is single-use and short-lived (30–60 seconds). Tokens never appear in the browser URL.
Step 1: Authorization request
import crypto from "node:crypto";
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString("hex");
// Store verifier and state in session
session.pkceVerifier = verifier;
session.oauthState = state;
const params = new URLSearchParams({
response_type: "code",
client_id: process.env.OAUTH_CLIENT_ID,
redirect_uri: "https://app.example.com/auth/callback",
scope: "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
res.redirect(`https://auth.example.com/authorize?${params}`);
Step 2: Callback and token exchange
app.get("/auth/callback", async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.status(400).send(`Auth error: ${error}`);
if (state !== req.session.oauthState) {
return res.status(403).send("Invalid state");
}
const tokenRes = 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,
redirect_uri: "https://app.example.com/auth/callback",
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
code_verifier: req.session.pkceVerifier,
}),
});
const tokens = await tokenRes.json();
// tokens: { access_token, refresh_token, expires_in, token_type }
});
Step 3: Token storage
// Set refresh token in HttpOnly cookie
res.cookie("refresh_token", tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 30 * 24 * 60 * 60 * 1000,
path: "/auth/refresh",
});
// Access token in server session or return to client memory
req.session.accessToken = tokens.access_token;
res.redirect("/dashboard");
Refresh flow
app.post("/auth/refresh", async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.status(401).json({ error: "no refresh token" });
const tokenRes = await fetch("https://auth.example.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
}),
});
const tokens = await tokenRes.json();
if (!tokenRes.ok) {
res.clearCookie("refresh_token");
return res.status(401).json({ error: "refresh failed" });
}
// Rotate refresh token if provider returns a new one
if (tokens.refresh_token) {
res.cookie("refresh_token", tokens.refresh_token, { /* same opts */ });
}
res.json({ access_token: tokens.access_token, expires_in: tokens.expires_in });
});
Security checklist
| Check | Why |
|---|---|
| PKCE on all clients | Prevents code interception |
Validate state |
Prevents CSRF on callback |
| Exact redirect URI match | Prevents open redirect attacks |
| Server-side token exchange | Keeps client_secret off the browser |
| Short access token TTL (15 min) | Limits stolen token window |
| Refresh token rotation | Detects token theft |
| HTTPS everywhere | Tokens in transit are plaintext otherwise |
Common mistakes
- Skipping
statevalidation — attacker tricks victim into logging into attacker's account. - Wildcard redirect URIs —
https://app.example.com/*allows subdomain takeover exploits. - Returning tokens in URL hash — that's the deprecated implicit flow.
- Not handling
errorquery param — users see a blank page on consent denial.
OIDC layer
Most providers implement OpenID Connect on top of OAuth 2.0. Add scope: openid and validate the id_token JWT:
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
);
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: "https://auth.example.com",
audience: process.env.OAUTH_CLIENT_ID,
});
// payload.sub is the user ID
PKCE required for public clients
const verifier = generateCodeVerifier();
const challenge = await sha256Base64Url(verifier);
// Authorize URL: code_challenge=...&code_challenge_method=S256
// Token exchange: code_verifier=verifier
Mobile and SPA apps must use PKCE — confidential client secret cannot be stored safely. Rotate refresh tokens on each use (OAuth 2.1 behavior).
Common production mistakes
Teams get authorization code flow wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
OAuth flows involving authorization code flow leak sessions when refresh tokens are stored in localStorage, redirect URI validation is loose in staging, and token introspection is skipped for opaque bearer tokens.
Debugging and triage workflow
When authorization code flow misbehaves in production, work top-down instead of guessing:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.
Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.
Resources
- RFC 6749 — OAuth 2.0 — authorization framework spec
- RFC 7636 — PKCE — proof key for code exchange
- OAuth 2.0 Security Best Current Practice — current security guidance
- Auth0 PKCE guide — practical implementation walkthrough
- jose JWT library — ID token verification
Frequently asked questions
Why is PKCE required for public clients?
Public clients (SPAs, mobile apps) cannot store a client secret securely. PKCE replaces the secret with a per-request code verifier and challenge. Without PKCE, an attacker who intercepts the authorization code can exchange it for tokens at the token endpoint.
What is the difference between authorization code and implicit flow?
The implicit flow returned tokens directly in the URL fragment—exposing them to browser history and referrer headers. It is deprecated. Authorization code flow with PKCE exchanges a short-lived code server-side for tokens, keeping access tokens out of the browser URL.
Where should I store tokens in a SPA?
Store refresh tokens in HttpOnly, Secure, SameSite cookies set by your backend. Keep access tokens in memory (JavaScript variable) for API calls. Never store tokens in localStorage—any XSS vulnerability exposes them.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →