cctbp.com | Tom Stacy, CISSP®
Every practitioner who has spent time in OAuth flows knows the authorization code exists for exactly one reason: to keep the access token out of the browser. The code travels through the front channel; the redirect, the query string, the browser history. The token is supposed to stay in the back channel, exchanged server-to-server where an attacker can’t see it.
That separation is the entire security model. When it breaks, it breaks completely.
This post covers authorization code interception; what the attack actually looks like end-to-end, where the seam is, and what it produces in logs. It’s the first technique post in this series, and it’s the right place to start because everything else in OAuth security is downstream of understanding this flow precisely.
All the scenarios in this post are reproducible against FlawedToken, a purpose-built vulnerable OAuth environment I’ve released alongside this series. One command and you have a running authorization server and client app with the misconfigurations active. Details at the end.
The Setup
OAuth 2.0 authorization code flow with PKCE, by the book. FlawedToken’s client is a public client, so PKCE (RFC 7636), not a client secret, is what proves the party redeeming the code is the party that started the flow:
- Client generates a random
code_verifier, derives its hash as thecode_challenge, and sends the user to the authorization endpoint withresponse_type=code,client_id,redirect_uri,state,code_challenge, andcode_challenge_method=S256 - User authenticates at the authorization server
- Authorization server issues a short-lived code and redirects the user’s browser to
redirect_uri?code=AUTH_CODE&state=STATE - Client application receives the code at its callback endpoint
- Client exchanges the code for an access token via a back-channel POST to the token endpoint, presenting
client_id, the code,redirect_uri, and thecode_verifierit generated in step 1 - Authorization server confirms the verifier hashes to the stored challenge and issues the access token
The code is single-use and short-lived, typically 60 seconds. The access token never touches the browser. The code_verifier never leaves the client. That’s the design.
The attack surface is step 3. The code travels through the browser as a query parameter. If an attacker can intercept it before the legitimate client’s callback handler fires, they can try to exchange it themselves.
The Seam
Two conditions make this possible:
Condition 1: The authorization server does not enforce PKCE at the token endpoint.
For a public client there is no client secret to prove that the party redeeming the code is the party that started the flow. PKCE fills that gap. The client generates a random code_verifier, sends only its hash (the code_challenge) on the authorization request, and presents the code_verifier itself at the token exchange. The server issues a token only if the verifier hashes to the challenge it stored. An intercepted code is useless without the matching verifier, and the verifier never leaves the client that started the flow.
The failure mode is a server that accepts a code_challenge at authorize time but never checks the code_verifier at token time. That is functionally identical to having no PKCE at all. The server issues the code, the code travels through the browser, and any party who presents it to the token endpoint gets a token.
State does not close this gap. The state parameter is a CSRF control and a detection signal, not a token-exchange binding. It rides in the same redirect as the code, so anyone positioned to steal the code steals the state with it. A state mismatch at the client’s callback is worth alerting on (it’s consistent with interception or CSRF), but it fires on the victim’s side after the code is already gone. It cannot stop the exchange.
Condition 2: The attacker can read the authorization code.
This happens more often than it should:
- The code appears in server-side access logs via Referer header leakage when the callback page loads third-party resources
- Browser history, proxy logs, or intermediary caches capture the redirect
- A logging misconfiguration writes query parameters to a log aggregator the attacker can reach
- An open redirect on the callback domain allows the attacker to redirect the code to a URI they control (covered in the next post)
- The attacker has a position on the network, even briefly
If either condition is absent the attack fails. In practice, condition 1 is common because PKCE enforcement is frequently incomplete. Condition 2 is the variable.
Reproducing It
Prerequisites
- FlawedToken running locally, setup below
FLAW_CODE_INTERCEPTION=onin your.env(default)- Intercepting proxy like mitmproxy, ZAP, or Burp Suite
- A second terminal for the token exchange
Step 1: Start the flow
Open http://localhost:8000 and click Login via OAuth. The client generates a code_verifier, derives the code_challenge, and redirects to the authorization server with the PKCE parameters attached:
http://localhost:8001/authorize?response_type=code
&client_id=flawedtoken-client
&redirect_uri=http://localhost:8000/callback
&scope=openid+profile+email
&state=<random_state>
&code_challenge=<code_challenge>
&code_challenge_method=S256
The code_verifier stays inside the victim’s client. Only the challenge (its hash) travels through the browser, so intercepting the request tells the attacker nothing they can replay.
Step 2: Authenticate as the victim
Log in with alice / password. The authorization server issues a code and redirects:
http://localhost:8000/callback?code=<AUTH_CODE>&state=<random_state>
Step 3: Intercept the code
In mitmproxy, intercept the redirect response before it reaches the callback. In the browser network tab, watch for the 302 from localhost:8001; the Location header contains the code in plaintext.
Copy the code value. Drop or forward the request; it doesn’t matter for the demo. If the legitimate client exchanges it first, your attempt will fail with invalid_grant. If you move fast enough, you win.
Step 4: Exchange the intercepted code
From a separate terminal simulating the attacker’s session:
curl -s -X POST http://localhost:8001/token \
-d "grant_type=authorization_code" \
-d "code=<AUTH_CODE>" \
-d "redirect_uri=http://localhost:8000/callback" \
-d "client_id=flawedtoken-client" | python3 -m json.tool
There is no client_secret (the client is public) and no code_verifier (you never had it; it stayed in the victim’s client). That’s the entire point: with PKCE unenforced, the token endpoint never demands proof that you started the flow.
Response:
{
"access_token": "gdvxrmPY45xmBqW48TjR9HFErkA9YSd...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}
The token belongs to alice. The legitimate client gets invalid_grant when it arrives at the callback, as the code has already been consumed.
Step 5: Validate the token
curl -s http://localhost:8001/userinfo \
-H "Authorization: Bearer <ACCESS_TOKEN>" | python3 -m json.tool
{
"sub": "alice",
"name": "Alice Example",
"email": "[email protected]",
"username": "alice"
}
Full account takeover. One intercepted code, no credentials required.
Fixed Behavior
Set FLAW_CODE_INTERCEPTION=off in .env and restart:
docker compose down && docker compose up
With the flaw disabled, the token endpoint enforces PKCE (S256). The intercepted-code exchange from Step 4 presented no code_verifier, so it is now rejected:
{
"error": "invalid_grant",
"detail": "pkce verification failed"
}
The legitimate client still succeeds, because it holds the code_verifier it generated at the start of the flow, and that verifier hashes to the stored challenge. That is the whole mitigation: PKCE cryptographically binds the code to a secret the client generates and never puts in the browser. An intercepted code without the matching verifier is worthless.
State is not a substitute for this. It travels in the same redirect as the code and can be intercepted alongside it, so it protects against CSRF and flags a mismatch after the fact, but it cannot bind the token exchange. The verifier can, because it never leaves the client.
Defender Perspective
This attack produces a specific signature in logs. What to look for:
Failed token exchange immediately following a successful one. The legitimate client arrives at the callback, posts to the token endpoint, and gets invalid_grant. The code was already consumed. A 400 invalid_grant on the token endpoint within seconds of a successful authorization response is the primary indicator.
Token exchange from a different IP than the authorization request. The authorization request originates from the victim’s browser. The attacker’s exchange comes from a different host. Correlate client_id and state across the authorization log and token log; a mismatch in source IP is suspicious.
Unusual user-agent on the token endpoint. Browser-driven flows produce a consistent user-agent on the authorization request. A curl or scripted exchange against the token endpoint has a different signature entirely.
Authorization codes in Referer headers. If your callback page loads any external resources (analytics, fonts, or CDN assets), the full callback URL including the code appears in the Referer header of those requests. Check your third-party request logs.
State mismatch at the client callback. When the state returned to the callback doesn’t match the value issued at login initiation, that’s consistent with interception or CSRF. It won’t stop the exchange, but as a client-side signal it’s cheap to emit and worth alerting on.
Detection rule sketch for a SIEM:
event: token_endpoint_request
AND response: invalid_grant
AND time_since_authorization_code_issued < 10s
→ alert: possible authorization code interception
Tune the time window to your authorization server’s code TTL. FlawedToken defaults to 60 seconds.
Takeaway
For red teamers: Authorization code interception is rarely about a single sophisticated technique. It’s about finding one of several mundane conditions such as a logging misconfiguration, a third-party script on the callback page, or a Referer header leaking to an analytics endpoint, and combining it with an authorization server that doesn’t actually enforce PKCE at the token endpoint. The conditions are common. The attack is underutilized because practitioners haven’t built the muscle memory. That’s what this lab is for.
For defenders: The primary control is PKCE, enforced server-side at the token endpoint, not optional and not merely accepted at authorize time. The primary detection is correlating failed token exchanges against recent authorization codes by client and source IP. If your authorization server doesn’t log both endpoints with enough fidelity to do that correlation, fix the logging before you fix anything else.
The Lab: FlawedToken
The scenarios in this post run against FlawedToken, a purpose-built vulnerable OAuth 2.0 environment I’ve released as open source alongside this series.
git clone https://github.com/tstacy/flawedtoken.git
cd flawedtoken
cp .env.example .env
docker compose up
Client app: http://localhost:8000
Auth server: http://localhost:8001
Flaw status: http://localhost:8001/debug/flaws
Every misconfiguration is toggleable via environment variable. Set FLAW_CODE_INTERCEPTION=off to see the fixed behavior side by side with the broken one. The /docs directory has full walkthroughs for each flaw.
FlawedToken is intentionally broken. Use it in authorized lab environments only.
FlawedToken on GitHub → github.com/tstacy/flawedtoken
What’s Next
The next post covers redirect URI manipulation, the other half of the authorization code attack surface. Instead of intercepting the code in transit, the attacker gets the authorization server to deliver it directly to a URI they control. Same outcome, different entry point, and far more common in the wild than most practitioners realize.
The lab scenario runs against FlawedToken with FLAW_REDIRECT_URI_VALIDATION=on. The walkthrough is already in /docs if you want to get ahead of it.
The waitlist for ShroudCloud (the platform being built to automate these flows against authorized targets) is open at shroudcloud.com.
Tom Stacy, CISSP®, is an authentication and identity security specialist. He writes at cctbp.com and is building ShroudCloud™, an engagement infrastructure for auth and session security.