← Blog
October 26, 20269 min readAppSecVulnerability ResearchBug bountyAccess ControlSecurity Engineering

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 session
/authorize
redirect_uri set
User authenticated
often silently
302 with code
to redirect_uri
Token exchange
code -> session
The code is a short-lived credential; whoever exchanges it first wins the session.

The 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_uri you send. This is the PortSwigger lab case and it still appears in the wild.
  • Prefix or substring matching — https://app.example.com is allowed, so https://app.example.com.attacker.net or https://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.com or /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_uri matches exactly, but the page it points to forwards the browser (and the code in the query string, or via the Referer header) 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.

A 302 lands on your server
Error / no code
The server rejected the request or issued no code — nothing to replay.
Your own code
A code bound to your attacker account — exchanging it logs you in as yourself.
A victim's code
A code issued for another user's session that you can exchange — takeover.
The redirect looks identical in all three; only the third 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.

Scope and consent are not optional here. Trigger the flow only in a browser session for an account you own, and capture codes only at infrastructure you control. Never induce a real user to start the flow, and never exchange a code that was issued for someone else's real session — that is account compromise, not testing. One controlled takeover between two accounts you own is proof enough; you do not need a real victim to show the bug is real.

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_uri differs from the one the code was issued for, as RFC 6749 already required. Use state (or PKCE, per §4.7.1) for CSRF, not as a substitute for any of the above.
Exact matching stops the obvious forgeries, a clean redirect host closes the open-redirect laundering, and PKCE is what saves you when a code leaks anyway — which, eventually, one will. No single layer is the answer; the ones that survive a bug in the layer above are.

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

Ready to see it prove a bug?

Start scanning on Cloud →