Bypassing PKCE
Methodology write-up. No client systems or live findings are described here. The flow below is reproduced against a throwaway lab app.
PKCE (Proof Key for Code Exchange) exists to stop authorization-code interception. The flow is elegant on paper. In practice, the protection is only as strong as the verification step — and that is where things go wrong.
The thirty-second refresher
The client generates a random code_verifier, hashes it into a code_challenge, and sends the challenge up front. Later, when exchanging the code for a token, it sends the original verifier. The server hashes the verifier and checks it matches the challenge it stored. No match, no token.
Where the protection silently disappears
The mechanism only protects you if every link in the chain is enforced:
- Challenge not bound to the code. If the server does not tie the stored challenge to the specific authorization code, an attacker can mix and match.
plainmethod accepted. Some servers still allowcode_challenge_method=plain, which makes the challenge equal to the verifier — defeating the whole point.- Verifier check skipped on certain paths. Alternate token endpoints or grant types sometimes never re-run the comparison.
The exchange that should fail
Here is the token request I replay with a verifier that does not match the captured challenge. On a correct implementation it returns invalid_grant. On a broken one, it hands over 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
If a 200 OK with an access_token comes back, PKCE is present in the request but absent in the enforcement.
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.
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.