PKCE downgrade and bypass
TL;DR: PKCE (Proof Key for Code Exchange, RFC 7636) was added to OAuth to defend public clients against authorization code interception. Bypasses come from: server not enforcing PKCE when client doesn’t send it,
plainmethod accepted, code-verifier reuse, verifier guessability, and confused-deputy attacks where attacker uses a legitimate but unrelated client. OAuth 2.1 mandates PKCE for all clients — but enforcement is server-side and often optional.
What it is
PKCE adds two values to the auth code flow:
code_verifier: a random secret created by the client and kept private.code_challenge: a transform ofcode_verifier(SHA256 + base64url by default, calledS256;plainechoes the verifier).
Authorization request includes code_challenge and code_challenge_method. Token exchange request includes code_verifier. Authorization server verifies that SHA256(code_verifier) == code_challenge before issuing tokens.
Goal: even if attacker intercepts the auth code in transit (e.g., browser history, IPC hijack on mobile), they can’t redeem it without the verifier.
Bypasses
1. Server doesn’t enforce PKCE when challenge missing
Spec says: if client sent a challenge, server MUST verify a matching verifier. But it doesn’t force clients to send a challenge. Misconfig: client omits PKCE, server accepts. Now interception is fatal again.
- Test: send authorization request with no
code_challenge. If server proceeds, PKCE not enforced. - Fix (server): require
code_challengefor all public clients (OAuth 2.1 default).
2. plain method accepted
code_challenge_method=plain means code_challenge = code_verifier. Interception of either yields both. Equivalent to no PKCE.
- Test: send
code_challenge_method=plainwith a fixed value. Token exchange with same value succeeds. - Fix: server only accepts
S256per OAuth 2.1.
3. Downgrade via metadata
Some servers honour client metadata declaring “no PKCE required” or “plain OK”. Attacker who can register/modify a client (or compromise registration endpoint) downgrades.
- Fix: organization-level policy override; ignore client metadata that lowers security.
4. Verifier guessability
RFC requires 43–128 chars random. Some libs use weak RNG (Math.random, time seed). Predictable verifier → attacker brute-forces / predicts.
- Test: capture multiple verifiers from a client app; check for randomness.
- Fix: use CSPRNG, RFC-minimum 43 chars.
5. Verifier reuse
PKCE assumes one verifier per code. Some implementations cache verifier across multiple flows. Attacker reuses captured verifier on a fresh code.
- Test: complete a flow, capture verifier; start a new flow on same client, try the old verifier.
- Fix: bind verifier to a specific challenge in a single-use cache; invalidate on any code redemption attempt.
6. Confused deputy via shared verifier storage
Mobile app shares verifier storage across components (e.g., custom URL scheme, Intent extras). Another app or web view reads it.
- Fix: hardware-backed keystore for verifier; OS-level same-app isolation.
7. code_challenge not bound to redirect target
Some servers verify the challenge but not its binding to the redirect URL. Attacker initiates flow with their own redirect_uri and challenge, victim somehow ends up with the code, attacker redeems.
- Fix: server stores
(code, redirect_uri, code_challenge, client_id)tuple atomically; all four must match on token exchange.
8. Authorization server caches challenge by code_id only
Same as above but at storage layer. Two flows with the same code prefix collide.
9. PKCE missing for confidential clients
Spec originally exempted confidential clients (server-side apps with client secret). OAuth 2.1 still recommends PKCE for them, but many servers don’t enforce. If client secret leaks (env file in repo, CI logs), no PKCE = no second factor.
- Fix: PKCE required everywhere per OAuth 2.1.
10. Implementation bugs
- Server compares
code_verifierinstead ofSHA256(code_verifier)tocode_challenge(i.e., implementsplainmode internally even whenS256was requested). - Base64url vs base64 confusion — padding bytes accepted, allowing similar verifiers to validate.
- Constant-time compare missing — timing-based discovery of verifier.
Testing methodology
Black-box / dynamic
- Enumerate every client (web app, mobile app, SPA).
- Capture an authorization request → check
code_challenge/code_challenge_methodpresence and value. - Try removing challenge → does server proceed?
- Try
plainmethod → does server accept? - Try short or fixed-value verifier on token exchange.
- Try reusing a verifier across flows.
Source review
- Find the authorization server’s PKCE verifier comparison — must be constant-time, must check method, must reject
plain. - Find the storage record format — must bind
(code, redirect_uri, code_challenge, client_id). - Find the challenge required-or-optional gate.