
The defense against an intercepted OAuth authorization code isn’t a clever rate-limit or a long-lived state cookie. It’s PKCE — Proof Key for Code Exchange — and the entire security argument hinges on one cryptographic fact: the authorization server will only redeem a code when the redeemer also produces the matching code_verifier. That’s the lesson behind every public-client OAuth integration that has held up to scrutiny, and it’s the same flow the Elysia Tools OAuth 2.0 / OIDC Authorization Code with PKCE Flow Visualizer walks through end-to-end without you having to register an app.
If you’ve ever inspected an OAuth 2.0 authorization-code exchange in DevTools and wondered whether the code parameter floating back to your client was, on its own, enough to mint an ID token, you’re not alone. Plenty of teams have shipped a public-client redirect handler that quietly trusts a single-use authorization code from a third-party domain. It works until a network observer or a misconfigured redirect_uri pulls that code out of the URL bar before your client exchanges it.
What PKCE actually adds to the standard Authorization Code flow
The OAuth 2.0 Authorization Code grant (RFC 6749 §4.1) was designed for confidential clients — server-side apps that can hold a client_secret. Mobile apps, single-page apps, and native desktop apps cannot keep a secret. Before PKCE (RFC 7636), those clients exchanged an authorization code for tokens using only their public client_id, leaving the code itself as the bearer credential. If an attacker intercepted the redirect (a malicious app on the same registered redirect_uri scheme, a log file that captures query strings, a screenshot taken at the wrong moment), they could redeem the code from anywhere — the authorization server had no way to know the redeemer wasn’t the legitimate client.
PKCE closes that gap with two values the client generates locally: a high-entropy random code_verifier (43–128 URL-safe characters) and a derived code_challenge, normally BASE64URL(SHA256(code_verifier)). The client sends only the code_challenge (and code_challenge_method=S256) on the authorization request. When the code comes back, the redeeming client must send the original code_verifier. The authorization server hashes it, compares against the challenge it stored at request time, and rejects the exchange on any mismatch. An interceptor who scraped the redirect sees the code but never the verifier, so the redemption attempt fails. This is the lesson behind every “why public clients need PKCE” article — and it’s the same lesson the visualizer demonstrates by replaying both legs of the exchange and showing the at_hash mismatch that an attacker would receive.
The five concrete inputs you control on every authorization request
A real-world /authorize URL isn’t a mystery bag of params. Five fields drive the security argument and the validation chain:

– client_id — your registered public identifier; never a secret. – response_type=code — the grant type; PKCE only applies here. – redirect_uri — must exactly match a registered URI; mismatch is the canonical reason for redirect_uri_mismatch errors. – scope — openid profile email for OIDC; the bare openid is what flips the flow from pure OAuth into identity. – code_challenge and code_challenge_method=S256 — the PKCE pair; the method must be S256, not plain, for any client that can do SHA-256 (which is every client, always).
Three additional params belong in the OIDC variant: nonce (replay protection at the ID-token layer) and the optional acr_values and claims (requesting specific authentication strength or claim sets). The visualizer generates the verifier/challenge pair deterministically so you can copy a known-good URL into your own handler.
The token-exchange request body — what the redeeming client posts
After the user authenticates and the authorization server redirects to your callback with ?code=...&state=..., the client immediately redeems the code. The request is a application/x-www-form-urlencoded POST to the token endpoint:
grant_type=authorization_code
&code=
&redirect_uri=
&client_id=
&code_verifier= For confidential clients you also send client_secret (Basic auth header or POST body). For public clients, the code_verifier is the only thing standing between an attacker and your tokens — that’s why leaking it defeats the entire exchange.
The visualizer replays this exact body, then shows the spec-correct response: an access_token, an id_token (JWT, signed HS256 in the demo), the token_type=Bearer, the lifetime in expires_in, and optionally a refresh_token. Skim the response — every field name and casing matters when you’re parsing it on the client.
Why the id_token is the load-bearing artifact, and what at_hash proves
OAuth by itself returns opaque access tokens. OpenID Connect layers a signed JWT — the id_token — on top, and that token is what your client uses to establish who the user is. The ID token’s at_hash claim is what ties it back to the access token: it’s the left-most 128 bits of SHA-256(access_token), base64url-encoded. If the access token you later present to your resource server doesn’t produce the at_hash recorded in the ID token, the user was authenticated against a different token than the one you’ll use to call APIs — classic mix-up attack territory.
The visualizer mints a spec-correct HS256 ID token with a real at_hash derived from the minted access token, so you can decode it (eyJ... segments), inspect the header and payload, and verify the at_hash yourself. That’s the closest thing to a “did this client correctly implement OIDC Core §2” sanity check you can do without standing up your own IdP.
The seven-step validation checklist OIDC Core §3.1.3.7 expects your client to run
Every line of the spec is a separate failure mode:

- Issuer (
iss) matches the expected authorization server — a differentissmeans a different IdP entirely. - Audience (
aud) contains yourclient_id— a token issued for someone else’s app is not yours to trust. - Signature validates against the issuer’s JWKS — fetch
/.well-known/jwks.json, verify thekid, check the chain. - Issued-at (
iat) is in the past, not the future, within clock skew — replay window hygiene. - Expiration (
exp) is in the future — expired tokens are unsigned credentials; reject them. - Nonce (
nonce) matches what you sent on the authorization request — defeats replay across redirects. at_hashmatchesSHA256(access_token)— defeats token mix-up.
The visualizer replays this checklist end-to-end and tells you which line is failing, so a developer can debug a real implementation against the spec rather than against tribal knowledge. If you’ve ever had a senior engineer say “just trust the JWT lib,” this is the receipt for why that’s a load-bearing claim.
Why an intercepted authorization code is harmless in a PKCE client
Walk the attacker’s day. The victim’s browser navigates to your authorization endpoint. The IdP redirects to https://app.example.com/cb?code=A1B2&state=xyz. An attacker who can read that URL bar (browser history, an XSS that logs location.href, a screenshot tool, a log file in the wrong place) now has code=A1B2. They try to redeem it from their own server: same client_id, same code, same redirect_uri. The IdP computes SHA256(their_verifier) and compares against the code_challenge it stored. They don’t have the verifier — only the legitimate client does. Redemption fails. The tokens never leave the IdP. The attacker is left holding a useless 30-character string.

The visualizer demonstrates this exact scenario with a “Replay attack” panel that submits a known code with a wrong verifier and shows the IdP’s invalid_grant response. If you’re auditing a public-client codebase and can’t find a code_verifier parameter anywhere on the redemption path, you have a real bug.
When the state parameter matters even with PKCE
PKCE protects the token exchange. state protects the callback itself. A request without state is vulnerable to authorization-code injection: an attacker starts their own OAuth flow, captures the resulting code from their own IdP interaction, and CSRF’s it into your callback. Your client accepts the code, exchanges it for tokens, and now the attacker’s identity is bound to the victim’s session. PKCE doesn’t fix this — the verifier still matches because the attacker generated both — so state (a nonce you generate, store in the session, and verify on callback) is mandatory, not optional. The visualizer generates a state and checks it on the simulated callback so you can see what your code should look like.
Putting it together — what to actually build and what to avoid
Use a vetted OIDC client library rather than rolling your own — openid-client for Node, oauthlib for Python, AppAuth for mobile. Generate the verifier with 64 random bytes from a CSPRNG, base64url-encode it (no padding), and never log it. Send code_challenge_method=S256 always; reject any IdP that offers only plain. Validate every field in §3.1.3.7; a missing check is a real bug, not a stylistic preference. And if you want to see every parameter, every JWT segment, and every spec-checklist step before you wire it into your app, the OAuth 2.0 / OIDC Authorization Code with PKCE Flow Visualizer is the shortest path. For canonical request/response examples you can copy into a test harness, the OAuth 2.0 Samples and OAuth 2.0 & OpenID Connect Samples pages show real working bodies alongside the spec text.
The cryptographic property — that a code_verifier you never put on the wire defends a code you did — is what makes public-client OAuth defensible at all. The rest of the spec is plumbing around that one fact.