Almost every login page has one: /login?next=/account/settings. You sign in, the server reads next, and sends you back where you were. It's a small convenience, and it's one of the most common places a site ends up forwarding its users to a URL somebody else chose.
The bug itself is old and well understood. What keeps it alive is the check developers put in front of it. next.startswith("/") looks airtight. So does "the URL contains our domain". Both fail, and they fail for the same reason: your server-side code and the user's browser don't read URLs the same way.
This post covers where open redirects hide, why the usual checks miss, how to prove one without overclaiming, and a validator that holds up because it parses like the browser does. All examples use stand-in hosts (app.example.com for the app, evil.example for "somewhere you don't control"). Only test systems you own or are authorized to test.
What an open redirect is
An open redirect is an endpoint that sends the browser to a destination taken from user input without checking it properly. MITRE files it as CWE-601, "URL Redirection to Untrusted Site". The OAuth 2.0 Security Best Current Practice (RFC 9700, January 2025) defines an open redirector the same way: an endpoint that forwards the browser to an arbitrary URI taken from a query parameter.
On its own, the impact is mostly about trust. A link that starts with your real domain and a valid certificate is far more convincing in a phishing message, and the OWASP cheat sheet and PortSwigger both make that point. The impact grows when the redirect sits next to something sensitive:
-
OAuth. If an authorization server lets the
redirect_uripoint at any page on the client's domain, an open redirect on that domain can forward the authorization code or token to the attacker. RFC 9700 says clients and authorization servers MUST NOT expose open redirectors for exactly this reason. - Server-side fetches. An SSRF filter that only allows "our own domain" can be walked around if one of your own URLs redirects elsewhere. PortSwigger has a lab built on exactly that.
-
javascript:URLs. When the destination is written into a client-side sink such aslocation.href, controlling the start of the string can turn a redirect into script execution in your origin.
Where to look
Server-side redirects usually come from a parameter that ends up in a Location header. Search proxy history for 3xx responses whose Location contains something you sent. Names that come up a lot: next, returnUrl, return_to, redirect, redirect_uri, continue, url, dest, target, goto.
The usual spots:
- Login and logout flows ("send them back where they were").
- SSO, OAuth and "connect your account" callbacks.
- "You are leaving our site" interstitials and link trackers (
/out?url=). - Language or region switchers that bounce you back to the previous page.
Client-side redirects never touch a Location header. PortSwigger's DOM-based open redirection page lists the sinks to grep for in JavaScript: location, location.href, location.assign(), location.replace(), open() and others. If a value from location.search, location.hash or postMessage flows into one of those, read the code path.
Why the usual checks fail
Here are three checks I see in code reviews, and one that parses properly, run against inputs a browser treats as off-site:
from urllib.parse import urlsplit
def naive_starts_with_slash(value):
return value if value.startswith("/") else "/"
def naive_contains_host(value):
return value if "app.example.com" in value else "/"
def naive_prefix(value):
return value if value.startswith("https://app.example.com") else "/"
The output below is from running them locally. "pass" means the check would redirect to the input as-is:
input startswith(/) contains host prefix safe_next
/account/settings pass block block pass
//evil.example/ pass block block block
/\evil.example pass block block block
/<TAB>/evil.example pass block block block
https://app.example.com@evil.example/ block pass pass block
https://app.example.com.evil.example/ block pass pass block
https://evil.example/?app.example.com block pass block block
https:evil.example block block block block
javascript:alert(document.domain) block block block block
/.//evil.example pass block block block
https://docs.example.com/guide block block block pass
I checked where a browser actually lands with Node's URL class, which implements the same WHATWG URL Standard browsers follow. Resolving each input against https://app.example.com/login:
-
//evil.example/is a scheme-relative URL. It keepshttps:and replaces the host. It starts with/, so the first check waves it through. -
/\evil.examplelands onevil.example. ForhttpandhttpsURLs the standard treats a backslash like a forward slash (it flags a validation error, then carries on). Python'surlsplitsees a path with no host, which is why server-side "is it relative?" checks get this wrong. -
/<TAB>/evil.example(a literal tab character) also lands onevil.example. The URL parser strips ASCII tabs and newlines before parsing, so it becomes//evil.example. -
https://app.example.com@evil.example/is a URL with userinfo: everything before the@is a username, and the host isevil.example. Substring and prefix checks both pass it. The OWASP cheat sheet links a GitLab write-up of exactly this kind of bypass. -
https://app.example.com.evil.example/is just a subdomain ofevil.example.
The /.//evil.example row is the sneaky one, and it deserves its own section.
The bug in the "fixed" version
A common fix is to parse the input with a real URL parser, check the origin, then redirect to the path:
const u = new URL(value, "https://app.example.com");
if (u.origin === "https://app.example.com") {
res.redirect(u.pathname + u.search); // looks safe
}
Feed it /.//evil.example. The parser resolves the dot segment, the origin is https://app.example.com, the check passes, and u.pathname is now //evil.example. Send that back in a Location header and the browser reads it as a scheme-relative URL pointing at evil.example. I confirmed both steps in Node: the validated URL is same-origin, and re-resolving its pathname lands off-site.
The lesson matches what the OWASP cheat sheet says: redirect to the URL you validated, not to something you rebuilt from it afterwards.
How to prove it, without overclaiming
A reflected value in a Location header is a lead, not a finding. The thing that matters is where a real browser ends up. A check that takes two minutes:
- Use a destination you control. A domain you own, or a placeholder that can't resolve. You only need to show the browser leaving the site.
- Read the raw response first.
curl -s -o /dev/null -D - "https://app.example.com/login?next=//evil.example/"
You're looking for a 3xx with Location: //evil.example/ (or whatever form you sent). Note what the server did to your input: kept, re-encoded or stripped.
- Follow it in a real browser, because the browser's parser is the one that decides. Some flows only redirect after login, or only for a logged-in session, so walk the actual path a user would take.
- Write down the trigger and the outcome. The URL, the steps (for example "sign in, then you land on evil.example"), and a screenshot of the final address bar.
Then be honest about severity. A plain redirect after login is real but usually low impact, and many bug bounty programs treat it that way unless you show more; Bugcrowd's Vulnerability Rating Taxonomy, for example, lists a GET-based open redirect as low priority. If the same endpoint is reachable from an OAuth flow, or from a server-side fetcher, that's where real impact lives, and you prove it the same way: on your own test accounts and a lab or staging setup, never by collecting anyone else's tokens. Detection isn't proof, and a bare redirect isn't an account takeover until you've actually shown the chain.
How to fix it
In order of preference, following the OWASP Unvalidated Redirects and Forwards Cheat Sheet:
- Don't take a URL at all. Store the return path server-side (in the session, before the login form), or accept a short ID that maps to a known destination.
-
If you accept a local path, use a framework helper that rejects non-local URLs. ASP.NET Core has
LocalRedirectandUrl.IsLocalUrl. Check what yours offers before writing your own. -
If you write your own, parse with the browser's rules and redirect to what you validated. In Node, that's the WHATWG
URLclass:
const APP_ORIGIN = "https://app.example.com";
function safeNext(value, fallback = "/") {
if (typeof value !== "string" || value.length > 2048) return fallback;
let url;
try {
url = new URL(value, APP_ORIGIN); // the same WHATWG parser the browser uses
} catch {
return fallback;
}
if (url.origin !== APP_ORIGIN) return fallback;
return url.href; // redirect to what you validated, not a piece of it
}
Run against the inputs above, every off-site one falls back to /. /.//evil.example becomes https://app.example.com//evil.example, an absolute URL that stays on your host. javascript: URLs fail the origin check because their origin is opaque ("null").
In Python, the standard library parser doesn't follow the WHATWG rules, so the safe move is to refuse anything it might read differently from a browser and keep the allowlist explicit:
from urllib.parse import urlsplit
ALLOWED_HOSTS = {"app.example.com", "docs.example.com"}
def safe_next(value, default="/"):
if not value or len(value) > 2048:
return default
# Browsers strip tabs/newlines and treat "\" as "/" in http(s) URLs.
# Python's parser doesn't, so refuse anything it would read differently.
if any(ord(c) < 0x21 or c == "\\" for c in value):
return default
parts = urlsplit(value)
if not parts.scheme and not parts.netloc:
# A local path: exactly one leading slash, no "./" tricks left for the browser.
if value.startswith("/") and not value.startswith("//") and "/./" not in value \
and "/../" not in value and not value.endswith(("/.", "/..")):
return value
return default
if (parts.scheme == "https"
and parts.hostname in ALLOWED_HOSTS
and parts.username is None and parts.password is None
and parts.port in (None, 443)):
return value
return default
That's the safe_next column in the table above: it passes the local path and the allowlisted docs URL, and blocks everything else. It's deliberately strict. Rejecting a weird-looking but harmless return URL costs a user one extra click; accepting a bad one costs a lot more.
Two more habits that help:
- Turn the bypass list into tests. Every input in the table above belongs in your test suite with the expected result. When someone "simplifies" the validator later, the tests catch it.
- Interstitials for external links. If you genuinely need to send users off-site, a "you're leaving app.example.com for …" page with the destination shown is the cheat sheet's suggestion, and it makes phishing through your domain much less useful.
Try it yourself
Only test what you own or are authorized to test. These targets exist for it:
- PortSwigger Web Security Academy: DOM-based open redirection. Find the sink in the page's JavaScript and work out what reaches it.
- PortSwigger Web Security Academy: Stealing OAuth access tokens via an open redirect and SSRF with filter bypass via open redirection vulnerability, for the two chains where the impact really grows.
-
OWASP Juice Shop (
docker run --rm -p 127.0.0.1:3000:3000 bkimminich/juice-shop): its "Unvalidated Redirects" category has an "Outdated Allowlist" and an "Allowlist Bypass" challenge. - Paste the
safeNextfunction into a local Node file and throw your own inputs at it. Try to find one that passes the check but leaves the site. If you do, add it to your tests.
Conclusion
Open redirects survive because the checks in front of them look right. startsWith("/") misses // and /\, substring checks miss userinfo and look-alike subdomains, and even a proper parse can be undone by redirecting to a piece of the URL instead of the whole thing. Parse with the browser's rules, compare origins or an explicit allowlist, redirect to exactly what you validated, and keep the bypass list as tests. When you're on the other side of the report, prove where the browser actually lands, and claim the impact you can show, not the impact you can imagine.
Further reading
- Unvalidated Redirects and Forwards Cheat Sheet (OWASP)
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect') (MITRE)
- RFC 9700: Best Current Practice for OAuth 2.0 Security, sections 2.1 and 4.11 (IETF)
- URL Standard (WHATWG), the parsing rules browsers follow
- DOM-based open redirection and OAuth 2.0 authentication vulnerabilities (PortSwigger)
- URL validation bypass cheat sheet (PortSwigger)
- Vulnerability Rating Taxonomy (Bugcrowd)
- Prevent open redirect attacks in ASP.NET Core (Microsoft)
I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.
🔐 If this was useful, follow me for more hands-on web & API security write-ups.
🔗 Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/

Top comments (0)