Why architecture matters here
The architecture matters because OAuth is the front door to everyone's data, and the authorization code is the most sensitive artifact in the flow — a bearer credential that, if redeemed, yields long-lived access tokens. The front channel it travels on (the browser redirect, a mobile OS URI dispatch) is inherently untrustworthy: URLs land in server logs and browser history, custom URI schemes can be hijacked by a malicious app that claims the same scheme, and referrer headers leak. For a confidential client the code alone is insufficient because redemption also requires the client_secret; for a public client there is no secret, so historically the code was the whole credential. That asymmetry is the vulnerability PKCE exists to erase.
PKCE matters because it converts the flow from 'possession of a code' to 'proof of possession of a secret you generated,' without requiring any pre-registered secret. The elegance is that the binding is cryptographic and per-transaction: each authorization request carries a fresh challenge, so intercepting one transaction's code tells the attacker nothing about that transaction's verifier and cannot be replayed into another. It needs no additional round trips, no PKI, and no shared state beyond what the authorization server already tracks for the code — it simply stores the challenge with the issued code and checks the verifier at redemption.
Getting this right is not optional hardening; it is the difference between a mobile or SPA login that resists code interception and one that can be silently hijacked. The broader architecture — state for CSRF, exact redirect-URI matching, short token lifetimes, refresh rotation, audience-scoped tokens — exists because OAuth's threat model is adversarial browsers, hostile apps, and network observers all at once. Each control blocks a specific attack: PKCE blocks code interception, state blocks login CSRF, redirect allowlisting blocks open redirects, short TTLs bound the damage of a leaked token, and rotation detects refresh-token theft. Skipping any one leaves a real, exploited-in-the-wild hole.
The direction of the standards makes this concrete. The original OAuth 2.0 offered several grant types, including the implicit flow, which returned tokens directly in the redirect fragment specifically because browsers of the era could not make cross-origin back-channel calls easily. That flow is now deprecated: it exposes access tokens on the front channel, cannot use PKCE's proof-of-possession, and has no clean refresh story. OAuth 2.1 — the consolidation of a decade of security best-current-practice into a single document — removes implicit and password grants entirely and makes the authorization code flow with PKCE the one grant for interactive clients, mandatory for public and confidential clients alike. Reading that trajectory tells you where to invest: not in supporting many legacy grants, but in implementing one flow correctly and completely. The parts that feel like boilerplate — generating a fresh verifier and state per request, matching redirect URIs exactly, validating every claim on the resource server — are not ceremony; each is the residue of a specific attack the ecosystem learned about the hard way, and the modern flow is essentially the list of those lessons encoded as required steps.
The architecture: every piece explained
Top row: the parties and the happy path. The client app (a SPA, mobile, or desktop app) begins every login by generating a random code_verifier and its code_challenge. It sends the user's browser to the authorization server's authorization endpoint with the challenge, its client_id, requested scope, redirect_uri, and a state value. The server authenticates the user (login, MFA) and asks for consent, then issues a short-lived, one-time authorization code and redirects the browser back to the client's registered URI carrying the code and the echoed state. The client then calls the token endpoint directly (back channel, not through the browser) with the code and the original verifier, and the server returns tokens.
Middle row: the cryptographic and anti-CSRF details. The code_challenge is BASE64URL(SHA-256(code_verifier)) with method S256 (a legacy plain method exists but should never be used); the verifier is 43–128 characters of high-entropy randomness. The redirect carries state, an unguessable value the client generated and must verify on return — this binds the response to the request the client actually started and defeats CSRF where an attacker tricks a victim into completing the attacker's login. The token exchange POSTs grant_type=authorization_code, the code, the redirect_uri, and the code_verifier; the server recomputes the hash and rejects the exchange unless it matches the stored challenge. The response contains an access token (scoped, short TTL), usually a refresh token, and, for OpenID Connect, an ID token (a signed JWT asserting who the user is).
Bottom rows: consumption and lifecycle. The client presents the access token to the resource server (the API), which validates it — checking signature/introspection, expiry, issuer, audience, and required scopes — before serving data; the resource server never sees the user's password or the authorization code. When the access token expires, the client uses the refresh token at the token endpoint to get a new one; rotation issues a new refresh token each time and invalidates the old, so a stolen-and-reused refresh token is detected (the server sees an already-used token) and the whole chain can be revoked. The ops strip names the enforcement surface: exact redirect-URI allowlisting, requiring PKCE on every client, short token TTLs, a working revocation path, and audience checks so a token minted for one API cannot be replayed against another.
End-to-end flow
Follow a user logging into a mobile banking app. On tapping 'Sign in,' the app generates a 64-byte random code_verifier, computes code_challenge = base64url(sha256(verifier)), stores the verifier in memory, and also generates a random state. It opens the system browser (not an embedded webview — embedded webviews are an anti-pattern because they let the app observe credentials) to https://auth.bank.com/authorize?response_type=code&client_id=mobile-app&redirect_uri=com.bank.app://cb&scope=accounts.read %20payments&state=xyz&code_challenge=abc123&code_challenge_method=S256.
The authorization server shows the bank's real login page in the real browser; the user enters credentials and completes MFA, then sees a consent screen listing the requested scopes. On approval the server generates a one-time authorization code, stores it alongside the received code_challenge and a 60-second expiry, and redirects to com.bank.app://cb?code=CODE123&state=xyz. The mobile OS routes that custom-scheme URI to the app. The app first checks the returned state equals the xyz it generated — if not, it aborts, because a mismatched state means the response is not for a login this app started (CSRF). State verified, the app makes a back-channel HTTPS POST to https://auth.bank.com/token with grant_type=authorization_code, code=CODE123, redirect_uri=com.bank.app://cb, client_id=mobile-app, and crucially code_verifier=<the original 64-byte value>.
Now the PKCE check does its job. The server looks up CODE123, finds the stored code_challenge=abc123, computes base64url(sha256(received_verifier)), and compares. If they match, the code was redeemed by the same client that started the flow, and the server returns an access token (scoped accounts.read payments, 10-minute TTL), a refresh token, and an OIDC ID token; it also marks CODE123 consumed so it can never be redeemed twice. The app now calls GET https://api.bank.com/accounts with Authorization: Bearer <access token>; the resource server validates the token's signature, expiry, issuer, audience (api.bank.com), and the accounts.read scope, then returns the data.
Contrast the attack. Suppose a malicious app on the same device registered the com.bank.app:// scheme and intercepted the redirect, stealing CODE123. Without PKCE it would POST the code to the token endpoint and receive the victim's tokens. With PKCE it must also supply the code_verifier — which never left the legitimate app's memory and never traversed the interceptable redirect. The attacker has the code but not the verifier, the hash check fails, and the exchange is rejected. The stolen code expires unused in 60 seconds. That is the entire security payoff: the front-channel artifact is no longer sufficient to obtain tokens.
Worth making explicit is what PKCE does not replace. It defends the code-interception step, but the other controls still carry their own load. If the app skipped the state check, an attacker could still run a login-CSRF attack by injecting their own authorization code into the victim's session — PKCE would happily validate the attacker's own verifier, because the flow was consistent, just initiated by the wrong party; only the state binding catches that. If the redirect URI were wildcarded, an attacker could register a lookalike callback and receive the code at their own endpoint, verifier check notwithstanding. And once tokens are issued, PKCE is done — their safety depends entirely on short access-token TTLs, refresh rotation with reuse detection, and the resource server validating audience and scope on every call. This is why the flow is best understood as a defense-in-depth stack rather than a single trick: PKCE removes the public-client code-theft vulnerability that motivated it, and the surrounding controls each remove a different attack, so a correct implementation is the intersection of all of them, not any one in isolation.