OAuthauthorization codePKCEredirect URIstatefront channelback channel

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.

▶ Run the labFollow the failure

Frame the problem

Security starts with a concrete asset, attacker capability and trust crossing.

Asset
The tokens, and the binding between the authorization request and the party that redeems it.
Attacker & capability
One who can observe or influence redirects — a malicious app registered on the same URI scheme, an open redirect on the client, a referrer leak, or a browser history.
Trust boundary
The split between the **front channel** (through the user's browser, observable and manipulable) and the **back channel** (direct server-to-server, authenticated and private).
AssetThreatAttack SurfaceTrust BoundaryVulnerabilityExploit PathImpactMitigationDefense in DepthResidual Risk

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.

Front channel carries the code; back channel carries the tokens
1. redirect: client_id, scope, state, code_challenge2. authenticate + consent3. redirect back with code4. code + code_verifier + client authUser agent (browser)Authorization serverCode (front channel, one-time)Tokens (back channel, TLS)Client backend
UserLLMAgentToolDataDecisionHumanGuardrail

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 client
3const challenge = base64url(sha256(verifier))
4const state = base64url(crypto.randomBytes(16)) // CSRF defence for the callback
5const nonce = base64url(crypto.randomBytes(16)) // binds an OIDC id_token to this request
6
7await sessionStore.put(sid, { verifier, state, nonce }) // server-side, not in a cookie payload
8
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 URI
13 scope: 'openid profile repos:read', // minimum needed
14 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 use
22 if (!saved) throw new AuthError('no pending authorization')
23 if (!timingSafeEqual(req.query.state, saved.state)) // the request came from us
24 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 clients
30 body: new URLSearchParams({
31 grant_type: 'authorization_code',
32 code: req.query.code,
33 redirect_uri: 'https://app.example/callback', // must match exactly
34 code_verifier: saved.verifier, // proves we started this
35 }),
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, for id_token replay.
  • PKCE with S256 for every client, public and confidential.
  • Codes single-use and short-lived; a second redemption revokes the grant.
  • redirect_uri must 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, never plain, and use PKCE for confidential clients too.
  • Redirect URIs must match exactly; an open redirect on the callback host turns into account takeover.
  • state prevents callback CSRF; nonce prevents id_token replay. They are different controls.

Boundary control exercise

This lesson uses the shared boundary-control exercise.

Boundary control check
Untrusted input / identity
Trust boundary
Privileged asset
Prevention may fail silently.

Follow the attack

Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.

  1. 1
    Attacker → intercept the code: a malicious app on the same URI scheme, a referrer leak, a log, or an open redirect on the client.
  2. 2
    Code → redeem: possible if the client is public and PKCE is absent, or if the client secret has leaked.
  3. 3
    Alternatively → callback CSRF: start a flow with the attacker's account and cause the victim's browser to complete it, linking the accounts.
  4. 4
    Tokens → resource server: act with the granted scopes until revoked.
Blast radius
  • 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.

Prevent
  • • 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.
Detect
  • • 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.
Respond & recover
  • • 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.
Residual risk
  • • 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.

Misconceptions

Claim
“PKCE is only for mobile apps.”
Reality
Current guidance applies it to every client. It defends against code interception generally, and costs one hash.
Claim
“`state` and `nonce` are the same thing.”
Reality
`state` protects the callback against CSRF; `nonce` binds an `id_token` to the request that asked for it. A login flow needs both.