PKCE Verifier Not Enforced on Token Exchange
PKCE — Proof Key for Code Exchange — exists to prevent authorization-code interception
in public clients that cannot hold a client secret. The client generates a random
code_verifier, hashes it to a code_challenge, and sends the
challenge at authorization time. At token exchange, it sends the original verifier. The
server hashes the verifier and checks it matches the stored challenge. No match, no token.
Unless the server skips the check.
The finding
A grey-box assessment of an OIDC-based application platform. The normal authorization
flow was captured — S256 method, properly generated verifier. The verifier was then
replaced with a deliberately wrong string and the token request replayed. On a correct
implementation this returns invalid_grant. This server returned 200 OK with
a valid access_token and a populated claims set. PKCE was present in every
request. The enforcement was not.
The exchange that should fail
Here is the token request replayed with a verifier that does not match the captured
challenge. On a correct implementation it returns invalid_grant. On this
one, it returned a token:
POST /oauth/token HTTP/1.1
Host: auth.acme-lab.test
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<captured_auth_code>
&code_verifier=deliberately_wrong_value
&redirect_uri=https://app.acme-lab.test/callback
&client_id=public-spa-client
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 3600
}
# 200 OK — verifier mismatch ignored, valid token issued
An attacker who intercepts a valid authorization code — via an open redirect, a
referrer header leak, or a misconfigured redirect_uri — can exchange it
for tokens with any verifier string. The stolen code alone is sufficient. PKCE is
supposed to make it useless without the matching verifier.
Two further weaknesses were confirmed in the same session: the authorization endpoint
accepted code_challenge_method=plain, which sets the challenge equal to the
verifier and defeats the hash step entirely, and authorization codes were not single-use —
the same code replayed twice produced two valid tokens.
The test timeline
Captured a normal flow. Confirmed the code_challenge / code_verifier pair and the S256 method on the authorize call.
Tried the plain downgrade. Re-sent the authorize request forcing code_challenge_method=plain — the server accepted it.
Replayed a code with a mismatched verifier. Token endpoint returned 200 OK with a valid access_token. Verifier never checked.
Re-redeemed the same code twice. Second exchange also succeeded — codes were not single-use.
Documented the chain. Request/response pairs, both downgrade and mismatch paths, and the code-reuse evidence attached to the finding.
Enforcing PKCE correctly
- Enforce verifier validation server-side on every token exchange. The check must happen even if the client omits the
code_verifierparameter entirely — an absent verifier on a PKCE-initiated flow must be rejected the same as a mismatched one. - Reject
code_challenge_method=plainunconditionally. Require S256 for all PKCE exchanges and return an error for any authorization request that does not specify the S256 method. - Make authorization codes single-use with a short TTL. A code that has been exchanged for tokens must be immediately invalidated. A replayed code must return an error regardless of verifier validity.
- Audit PKCE enforcement independently across every token endpoint and grant type. Alternate flows — device authorization, refresh token chains — may bypass the main
authorization_codevalidation path. Each endpoint must be tested independently.
The takeaway
PKCE is not a checkbox. "We implemented PKCE" and "PKCE actually protects us" are two different claims, and the gap between them is where the engagement value sits.