hard OAuth 2.0 Security — PKCE, state, id_token
OAuth 2.0 Security — PKCE, state, and id_token validation
The OAuth authorization-code flow is only as safe as three checks that a "happy-path" trace usually omits,
and each defends a specific, well-known attack: state stops login-CSRF / injected
callbacks, PKCE binds the authorization code to the client that started the flow (the fix for
public clients that can't hold a secret), and id_token claim validation stops token
substitution and replay. Signature-verified is not the same as valid.
1. state — CSRF protection on the callback
Before redirecting to the authorization server, the client generates a random state, stores it
(session/cookie), and sends it in the /authorize request. The server echoes it back on the
callback. The client must compare the returned state to the one it sent and drop
the request if they differ. Without it, an attacker can trick a victim's browser into completing an OAuth
callback the attacker initiated (login-CSRF: the victim ends up logged into the attacker's account, or an
injected code is swapped in). state ties the callback to the browser session that began the flow.
2. PKCE — bind the code to its originator (public clients)
A confidential client authenticates the token exchange with a client secret. A public client (SPA, mobile app) cannot keep a secret — it ships in the bundle. PKCE (Proof Key for Code Exchange) replaces the secret with a per-flow proof:
- Client generates a random
code_verifierand sendscode_challenge = BASE64URL(SHA256(code_verifier))on/authorize. - On the token exchange it sends the raw
code_verifier; the server checksBASE64URL(SHA256(code_verifier)) == code_challenge. - An attacker who intercepts the authorization
code(redirect interception, malicious app on the same custom-URI-scheme) cannot redeem it, because they don't have the matchingcode_verifier.
PKCE is now recommended for all clients, but it is mandatory in practice for public clients — the case a confidential-secret trace leaves uncovered.
3. id_token validation — claims, not just the signature
A valid signature only proves the issuer minted the token; it does not prove the token is for you, now, and for this login. Validate every claim:
aud(audience) must equal yourclient_id. Skipping this is audience confusion: a token legitimately issued for a different app (or a malicious app the user also used) is replayed to yours and accepted.issmust be your expected issuer;expnot passed (andiat/nbfsane).noncemust equal the random nonce the client sent in/authorize— this makes a captured id_token un-replayable (it's bound to one login attempt).- Signature verifies against the issuer's JWKS (rotate keys via the JWKS endpoint).
Judgment layer — why / why-not / alternatives / trade-off
- Why these three: each closes a distinct hole in the redirect dance — CSRF (state), code interception for secret-less clients (PKCE), token substitution/replay (claim validation). They are not optional hardening; the flow is exploitable without them.
- Why-NOT / boundaries: PKCE does not replace
state(different attacks: code binding vs CSRF) — a common mistake is thinking PKCE alone suffices. And none of this protects a token already stolen from a compromised device — that's the revocation/rotation story, not the flow. - Alternatives: the implicit flow (token returned directly in the redirect) is the deprecated predecessor — it exposes tokens in the URL/history and has no code-exchange step; auth-code+PKCE replaces it precisely because implicit can't be secured for public clients.
- Trade-off: PKCE adds a hash round-trip and per-flow state vs the simplicity of implicit — you pay a little complexity to remove token-in-URL exposure and code interception. Always worth it.
- Scope boundary — two common conflations: this is a browser redirect flow for authenticating a
user to a client; it is the wrong tool for machine-to-machine calls (use the
client_credentialsgrant — no user, no redirect). And anaccess_tokenis not an identity assertion: it says "the bearer may call these APIs," not "this is who logged in." Use theid_token(or the userinfo endpoint) for identity; a resource server should not accept an id_token as an API credential, nor infer the user from a raw access_token.
Pitfalls
- Treating a verified signature as "valid" and skipping
aud/nonce— the two most commonly-missed checks and the ones that enable substitution/replay. - Using PKCE but ignoring
state(or vice-versa) — they defend different attacks; you need both. - Storing
code_verifierwhere the attacker who intercepts the code can also read it (defeats PKCE). - Using the implicit flow for a new SPA — deprecated; use auth-code + PKCE.
Takeaways
state= CSRF/callback binding; compare returned vs sent, always.- PKCE = code bound to originator via SHA256(verifier); mandatory for public (SPA/mobile) clients.
- id_token: validate
aud,iss,exp,nonce+ signature — signature alone is insufficient. - PKCE and
stateare complementary, not substitutes; implicit flow is deprecated.
Code sketch: PKCE generator
import base64, hashlib, secrets
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=")
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier).digest()
).rstrip(b"=")
# /authorize?code_challenge=challenge&code_challenge_method=S256&state=...
# /token body includes code_verifier=verifier
Related pages
- OAuth 2.0 & OIDC — Traced “Login with Google” — the full authorization-code flow this page secures.
- Authorization — PKCE, Multi-Tenancy, Zanzibar & Revocation — token revocation and rotation after the flow completes.
- Session & Cookie Security — how the browser stores the resulting session.
Browser SPA hardening: Backend-for-Frontend (BFF)
For browser-based SPAs, the strongest modern pattern is often a Backend-for-Frontend (BFF): the browser completes the login through a small same-origin backend, and the BFF stores access and refresh tokens server-side instead of exposing them to JavaScript. The browser receives only an application session cookie, typically HttpOnly, Secure, and SameSite=Lax or SameSite=Strict. HttpOnly means an XSS payload cannot read the token value with document.cookie; SameSite limits cross-site cookie sending, reducing CSRF exposure; and the BFF can attach bearer tokens to upstream API calls from the server where injected browser script cannot steal them.
This does not make XSS harmless: injected script can still issue actions as the user while the page is open. It changes the failure mode from token theft and replay from anywhere until expiry to same-origin session abuse that must run through your BFF controls (CSRF checks, origin checks, step-up, rate limits, and audit logging). It is the right default when the SPA talks to sensitive APIs and you can afford a small backend tier.
OAuth 2.1 direction
The OAuth 2.1 draft folds the security BCP direction into the core model: new clients should use authorization code with PKCE, the implicit grant is deprecated, and PKCE is mandatory for public clients and expected broadly even for confidential clients. In practice: do not build a new SPA around implicit flow; use auth-code + PKCE, and consider a BFF when browser token theft is the main risk.
Re-authored and deepened for this guide, per RFC 6749/7636 (PKCE), OpenID Connect Core (id_token claim validation), and the OAuth 2.0 Security BCP. Re-authored/Deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on OAuth 2.0 Security — PKCE, state, id_token? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **OAuth 2.0 Security — PKCE, state, id_token** (System Design) and want to truly understand it. Explain OAuth 2.0 Security — PKCE, state, id_token from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **OAuth 2.0 Security — PKCE, state, id_token** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **OAuth 2.0 Security — PKCE, state, id_token** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **OAuth 2.0 Security — PKCE, state, id_token** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.