The Authorization Code Flow (with PKCE)
The flow that keeps tokens off the front channel: the browser carries only a one-time code, which the client exchanges for tokens over a back channel — with PKCE proving that the exchanging party is the one that started the request.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Why a code instead of a token
The user's browser has to be involved: it is where the user authenticates and consents. But the browser is also the least trustworthy place to put anything valuable — URLs land in history, in referrer headers, in server logs, and in any application registered to handle the URI scheme.
The authorization code flow resolves this by sending something through the browser that is nearly worthless on its own: a one-time authorization code, valid for seconds, usable once, and redeemable only by the client that requested it. The tokens themselves travel on the back channel — a direct server-to-server request from the client to the authorization server, over TLS, authenticated with the client's own credentials.
So if the code leaks — through a log, a referrer, or an intercepted redirect — the attacker still needs the client's secret to exchange it. That is the whole design, and it is why the older implicit flow, which returned tokens directly in the redirect fragment, is deprecated: it put the valuable thing in the observable channel.
PKCE: proving the redeemer started the request
The back-channel design assumes the client can keep a secret. A single-page application or a mobile app cannot — its code is on the user's device and anything embedded in it is extractable. For those public clients, the code alone would be sufficient for anyone who intercepts it, which on mobile was a real attack: a malicious app registering the same custom URI scheme receives the redirect and redeems the code.
PKCE (Proof Key for Code Exchange) fixes this without a stored secret. The client generates a random code_verifier per authorization request, sends only its hash (code_challenge = SHA256(verifier), with code_challenge_method=S256) in the front-channel request, and presents the original verifier when exchanging the code. The authorization server hashes it and compares. An attacker who intercepts the code does not have the verifier, which never left the client, so the code is unredeemable.
Current guidance is to use PKCE for all clients, confidential ones included. It costs almost nothing and defends against code interception regardless of whether a client secret is also in play. Always use S256; the plain method sends the verifier itself in the front channel and provides nothing.
1// ---- 1. Start the authorization request -------------------------------2const verifier = base64url(crypto.randomBytes(32)) // never leaves the client3const challenge = base64url(sha256(verifier))4const state = base64url(crypto.randomBytes(16)) // CSRF defence for the callback5const nonce = base64url(crypto.randomBytes(16)) // binds an OIDC id_token to this request6 7await sessionStore.put(sid, { verifier, state, nonce }) // server-side, not in a cookie payload8 9redirect('https://auth.example/authorize?' + new URLSearchParams({10 response_type: 'code',11 client_id: CLIENT_ID,12 redirect_uri: 'https://app.example/callback', // must match a pre-registered exact URI13 scope: 'openid profile repos:read', // minimum needed14 state, nonce,15 code_challenge: challenge,16 code_challenge_method: 'S256', // never 'plain'17}))18 19// ---- 2. Handle the callback -------------------------------------------20async function callback(req: Request) {21 const saved = await sessionStore.take(req.sid) // single use22 if (!saved) throw new AuthError('no pending authorization')23 if (!timingSafeEqual(req.query.state, saved.state)) // the request came from us24 throw new AuthError('state mismatch')25 26 // ---- 3. Exchange on the BACK channel, never in the browser ----------27 const tokens = await fetch('https://auth.example/token', {28 method: 'POST',29 headers: { authorization: basicAuth(CLIENT_ID, CLIENT_SECRET) }, // confidential clients30 body: new URLSearchParams({31 grant_type: 'authorization_code',32 code: req.query.code,33 redirect_uri: 'https://app.example/callback', // must match exactly34 code_verifier: saved.verifier, // proves we started this35 }),36 }).then((r) => r.json())37 38 // ---- 4. Verify the id_token, if this is a login -----------------------39 const claims = await verifyIdToken(tokens.id_token, { audience: CLIENT_ID, nonce: saved.nonce })40 return establishSession(claims.sub)41}Redirect URIs, state, and the mistakes in between
Redirect URI validation must be an exact match against a pre-registered value. Prefix or wildcard matching is repeatedly exploitable: a registration of https://app.example/callback matched by prefix also matches https://app.example/callback.evil.com under a careless implementation, and a registered path on a domain with an open redirect lets an attacker bounce the code onward to a host they control. Open redirects are usually filed as low severity; combined with an OAuth callback they are an account-takeover primitive.
`state` is a CSRF token for the callback. Without it, an attacker can initiate an authorization flow with their own account, capture the resulting code, and then cause a victim's browser to visit the callback with that code — linking the attacker's third-party account to the victim's session, or vice versa. Generate state randomly per request, store it server-side, and compare on return. Do not put a secret inside it; it is an identifier, not a container.
`nonce` is separate and does a different job: it binds an OIDC id_token to this specific authorization request so that a previously-obtained token cannot be replayed into a new login. Both are needed for a login flow; state alone for a pure API-access flow.
Two more, briefly. Codes must be single-use and short-lived, and a second redemption attempt should invalidate the grant, since it indicates interception. And the redirect_uri sent at exchange must match the one sent at authorization — a check that closes a family of mix-up attacks in multi-provider setups.
- Exact-match redirect URIs. No wildcards, no prefix matching, no open redirects anywhere on the callback host.
state: random, per-request, server-side, compared on return.nonce: separate, forid_tokenreplay.- PKCE with
S256for every client, public and confidential. - Codes single-use and short-lived; a second redemption revokes the grant.
redirect_urimust match between authorization and exchange.- Never carry tokens in a URL fragment to the browser — that is the deprecated implicit flow.
Key points
- The front channel carries a one-time code; tokens travel only on the authenticated back channel.
- PKCE proves the party redeeming the code is the one that started the request, without needing a stored secret.
- Use
S256, neverplain, and use PKCE for confidential clients too. - Redirect URIs must match exactly; an open redirect on the callback host turns into account takeover.
stateprevents callback CSRF;noncepreventsid_tokenreplay. They are different controls.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → intercept the code: a malicious app on the same URI scheme, a referrer leak, a log, or an open redirect on the client.
- 2Code → redeem: possible if the client is public and PKCE is absent, or if the client secret has leaked.
- 3Alternatively → callback CSRF: start a flow with the attacker's account and cause the victim's browser to complete it, linking the accounts.
- 4Tokens → resource server: act with the granted scopes until revoked.
- Account linking attacks connect a victim's session to an attacker-controlled third-party account, or the reverse.
- Intercepted codes without PKCE yield full tokens with the granted scopes.
- A wildcard redirect URI turns any subdomain takeover or open redirect into token theft.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • PKCE with `S256` universally; exact-match redirect URIs; single-use short-lived codes.
- • Random per-request `state` stored server-side, and `nonce` for OIDC logins.
- • Eliminate open redirects on any host that serves an OAuth callback.
- • Use a maintained OAuth/OIDC client library rather than assembling the flow by hand.
- • Alert on code redemption failures for `code_verifier` or `redirect_uri` mismatch — near zero when healthy.
- • Alert on repeated redemption attempts for the same code.
- • Alert on authorization requests with redirect URIs that do not match any registration.
- • Revoke grants issued through the affected flow and require re-authorization.
- • Fix the redirect validation or open redirect first; the code path is the entry.
- • Review account links created during the window for cross-account contamination.
- • A compromised device or browser sees the whole flow regardless of protocol design.
- • Provider-side redirect validation quality varies and is outside client control.
- • Users can be phished into approving a legitimate flow for a malicious client.