PKCE: testing authorization code interception locally

PKCE (Proof Key for Code Exchange, RFC 7636) makes a stolen authorization code useless on its own. In our local toy server without PKCE, a party holding only an intercepted code received an access token in all four interception flows. With an S256 code challenge enforced, the three interception attempts that reached the token endpoint got 400 invalid_grant, a client with no challenge was refused at the authorization endpoint, and the legitimate client still received its token.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

How the PKCE flow stops authorization code interception

In the OAuth authorization code grant, the authorization server sends a short-lived code to the client's redirect URI, and the client trades it for an access token. A public client such as a mobile app cannot keep a secret, so anyone who holds the code and the public client_id can make the same trade. RFC 7636 calls this the authorization code interception attack. Its example is a malicious app that registers the same custom URI scheme as the real app and receives the redirect.

The PKCE flow adds one secret that never travels in the redirect. For each authorization request the client creates a code verifier: a random string of 43 to 128 unreserved characters, and RFC 7636 recommends 32 random octets base64url-encoded, which gives 43 characters. It sends a code challenge in the authorization request. With the S256 method, the code challenge is the base64url-encoded SHA-256 hash of the code verifier. The server stores the challenge with the code. At the token endpoint the client sends the code verifier, the server hashes it and compares, and a mismatch must return invalid_grant.

This post covers PKCE only. Other OAuth weaknesses are out of scope.

Scope of the fixture

  • A toy authorization server with two endpoints, GET /authorize and POST /token, bound to IPv4 loopback 127.0.0.1 on a free port the runner picks.
  • One synthetic public client, demo-public-app, with the registered redirect URI demoapp://callback. Nothing listens there. curl never follows the redirect, so the runner reads the Location header, which is what a malicious app registered for that scheme would receive.
  • One user who is already signed in and consents. There is no login page.
  • The interceptor holds the code, the client_id and the redirect URI. It never sees the code verifier. In one flow it also saw the authorization request, so it knows the code challenge.

Vulnerable mode ignores code_challenge and code_verifier. Fixed mode enforces them. Each flow uses its own authorization code, because the server consumes a code on its first use. Tokens, codes and verifiers are one-time demo strings. To test a real system, agree hosts, accounts and stopping conditions with its owner first. The Acceptable Use Policy describes authorized use, and this article gives no permission to test another system. The fixture matches the redirect URI as one exact string and does not cover redirect handling in general, which open redirect covers.

Run the downloadable fixture

Download fixture.py and verify.py into one directory. You need curl and Python 3; the code uses only the standard library, and we ran it on Python 3.15.2 only. Read the source, then run:

python3 verify.py --output rerun

The output directory must be new. The runner starts each mode in turn, runs six flows per mode with real curl requests, saves every response and stops the servers. Each request bypasses proxies with --noproxy '*', has a one-second connect timeout and a three-second total timeout, and follows no redirects.

Our recorded command was python3 verify.py --output recorded-run, run on 11 November 2026 at about 09:58 UTC with fixture version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. It printed {"checks": 12, "passed": true, "failures": []}. That line means each flow matched what we expected from that mode, including the intended failures in vulnerable mode. It also recomputes the RFC 7636 Appendix B example, where the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk must produce the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM. That value matched. Versions, UTC times, commands and source SHA-256 values are in results.json.

Compare vulnerable and fixed mode, with controls

The legitimate client flow is the positive control: it must keep working after the fix. The three interceptor flows send a missing, a guessed and a replayed verifier. The last two rows start from clients that break the fixed server's rules: no challenge at all, and the plain method.

Recorded outcomes of 12 local flows
FlowRedeemer sendsVulnerable modeFixed mode
Legitimate client, S256 challengeits code verifier200, token200, token
Interceptor, no verifiernothing200, token400 invalid_grant
Interceptor, guessed verifiera random 43-character value200, token400 invalid_grant
Interceptor, challenge replayed as verifierthe code challenge200, token400 invalid_grant
Client sends no code challengenothing302 with code, then 200 token302 with error=invalid_request
Client sends method plainits code verifier302 with code, then 200 token302 with error=invalid_request

In vulnerable mode the interceptor got a token in all four of its flows (rows 2 to 5 of the table). The server accepted an S256 challenge in rows 2 to 4 and never checked it. In fixed mode no interceptor received a token. Rows 5 and 6 never reached the token endpoint because the authorization endpoint refused the request.

The refusals follow RFC 7636. Section 4.4.1 says a server that requires PKCE must answer a missing code_challenge with the invalid_request error, and a server that does not support the requested transformation must do the same. The fixed server supports S256 only. Section 4.3 makes the method default to plain when it is absent, and section 4.2 allows plain only for clients that cannot use S256 and know the server supports it. Refusing plain is therefore a server choice. Section 7.2 gives the reason: with plain, the challenge equals the verifier, so an attacker who observes the authorization request can learn the verifier. We did not run that attack.

The replayed-challenge row models the more capable attacker from section 1 of the RFC, who can also observe authorization requests. Replaying the observed challenge failed because the server hashes whatever it receives, so the result no longer equals the stored value. That shows one shortcut fails and nothing more about SHA-256.

Verify a missing-PKCE finding before you report it

A missing or unenforced code challenge is a finding only when an interceptor's request succeeds. Check both halves against a test account you control. First, send an authorization request with no code_challenge and see whether you get a code or an error. Second, redeem a code from a flow that did include a challenge, without the code verifier, and see whether you get a token. A server that rejects the first request but accepts the second enforces PKCE on one path only, and the second request is the one that matters for interception. Repeat for each registered public client, since enforcement can differ per client.

The recorded vulnerable-mode request for the interceptor with no verifier used port 50601. It carries the code the client's redirect returned and no code_verifier. This is the executed command, reformatted across lines:

curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request POST \
  --data 'grant_type=authorization_code&code=Awb_hZZmRxtiNaCBTgiQqw&client_id=demo-public-app&redirect_uri=demoapp%3A%2F%2Fcallback' \
  http://127.0.0.1:50601/token

The recorded response holds the token; headers are omitted:

HTTP/1.0 200 OK

{"access_token": "demo-access-c0c5d7751b00", "token_type": "Bearer"}

The matching authorization request had carried an S256 code_challenge, as the authorization response and commands.sh show. A report should name the client, the endpoints, what the authorization request contained, what the interceptor sent and the response it received. This run proves a token was issued without the verifier in a fixture. It gives no severity for a real system, and it does not show that anyone can read a real user's redirect. That depends on the platform.

Require an S256 code challenge and retest

The same fixture source holds both modes. In fixed mode the authorization endpoint refuses a request without a challenge or with any method other than S256:

if self.server.mode == "fixed":
    if not challenge:
        return self.redirect_error("invalid_request", "code challenge required", q)
    if method != "S256":
        return self.redirect_error("invalid_request", "transform algorithm not supported", q)

The token endpoint recomputes the challenge from the verifier it receives and compares it with the one stored for that code:

verifier = form.get("code_verifier", "")
ok = bool(verifier) and verifier.isascii() and hmac.compare_digest(s256(verifier), record["challenge"])
if not ok:
    return self.reply(400, {"error": "invalid_grant"})

The helper is s256(verifier), which returns the base64url encoding, without padding, of the SHA-256 hash of the ASCII verifier. The fixture returns invalid_grant for a missing verifier as well as a wrong one. RFC 7636 section 4.6 names invalid_grant for a mismatch and does not name the error for an absent verifier, so another server may answer differently.

The runner restarted the server in fixed mode on port 50617 and repeated the six flows. The interceptor with no verifier got 400 with {"error": "invalid_grant"}. The legitimate client's token request returned 200 with a token. The client with no challenge was sent to demoapp://callback with error=invalid_request and the description code challenge required, as recorded here. Refusing a request with no challenge breaks old clients. Update them to send a challenge before you enforce it on a live server.

Inspect the evidence bundle

The fixture is also on GitHub: web-security-fixtures. The bundle holds the raw response text, and all values in it are synthetic.

What this experiment does not cover

Twelve flows against a toy server show one boundary: with PKCE enforced, a code alone cannot be redeemed here. We did not test a real operating system hijacking a custom URI scheme, TLS, code expiry, confidential clients, refresh tokens, or redirect URI matching beyond one exact string. The fixture does not check the length or character set of the challenge or verifier against the 43 to 128 character rule in RFC 7636. It consumes a code on any attempt, so we never raced an attacker and the real client for one code. It also does not test the downgrade attack in section 7.2, where a man in the middle causes an S256 error to push the client to plain. The RFC says clients must not fall back to plain after S256 fails.

The authorization server's token rules come from RFC 6749, which says in section 10.5 that authorization codes must be short lived and single use. PKCE adds to those rules and does not replace them. For checks on the APIs behind the token, see API authorization testing.

Excession Labs runs this kind of check in its security assessments.