Open redirect, explained
Your domain, your TLS certificate, the attacker's destination. The bypasses that defeat every string check, and the one comparison that actually works.
- security
- vibe coding
- engineering
An open redirect is an endpoint on your domain that will send a visitor
anywhere, because the destination comes from the request. ?next=,
?returnUrl=, ?redirect=, ?continue=, ?url=: every login flow has one,
and most of them are validated with a string check that does not hold.
It is CWE-601. Triaged in isolation it usually gets a low severity, which is fair and is also why it stays in codebases for years. Its actual value is as an ingredient, and the recipes it appears in are not low severity at all.
Why a redirect is worth stealing
It launders a phishing link. The link in the email reads
https://yourapp.example/login?next=https://yourapp-example.attacker.com/. The
domain is yours, the TLS certificate is yours, the link preview in the mail
client shows your name, and any filter that allowlists your domain waves it
through. The victim clicks something legitimate and lands on a copy of your
login page. You supplied the trust; the attacker supplied the destination.
It leaks credentials through the referrer and the fragment. A redirect from
an authenticated page can carry the source URL in the Referer header to the
attacker’s server. Anything in that URL, including a reset token or an invite
code, goes with it.
It steals OAuth tokens. This is the severe one.
RFC 6749 section 10.15
calls open redirectors out by name, because an authorization server that
resolves redirect_uri loosely, or a client with an open redirect anywhere on
its registered domain, lets an attacker chain the two: the authorization code or
token is delivered to your domain, your redirect forwards it onward, and the
attacker receives it with a valid Referer or in the query string. The whole
reason OAuth requires exact string matching of registered redirect URIs
rather than prefix matching is this attack.
It bypasses SSRF and CSRF defenses. A server-side fetcher that validates a URL and then follows redirects has validated one destination and retrieved another, which is exactly why our own dispatcher refuses redirects entirely.
Every string check you are about to write is broken
The instinct is to check that the target starts with your domain or does not
start with http. Here is why each attempt fails, in the order people usually
try them.
startsWith("https://yourapp.example"). Defeated by
https://yourapp.example.attacker.com/, which starts with exactly those
characters and is a completely different site. This is the single most common
version of the bug.
includes("yourapp.example"). Worse. https://attacker.com/?x=yourapp.example
passes.
“It must start with /, so it is relative.” Defeated by //attacker.com,
which is a protocol-relative URL: browsers read it as https://attacker.com.
And by /\attacker.com and /\/attacker.com, which several parsers normalize
into the same thing.
“I strip //.” Defeated by https:/\attacker.com, by
https:\\attacker.com, and by percent-encoding (%2f%2fattacker.com) if
anything decodes after your check.
“I parse it and compare the hostname.” Closer, and still defeated by
userinfo: https://yourapp.example@attacker.com/ has hostname
attacker.com, with everything before the @ being a username. If you parse
with a permissive homegrown regex rather than a real URL parser, this passes as
“the host is yourapp.example.”
“I block javascript:.” There is also data:, and there is
java\tscript: with an embedded tab, which some browsers historically accepted.
Blocklists of schemes lose the same way blocklists always lose.
The fix, strongest first
Best: do not accept a URL at all. Most redirect parameters exist to return a user to a page inside your own app. That is a path, not a URL, and a path is far easier to constrain:
function safeNext(raw) {
// Must be a single-slash absolute path. Rejects "//evil", "/\evil", "https://evil".
if (typeof raw !== "string" || !/^\/[^/\\]/.test(raw)) return "/dashboard";
return raw;
}
res.redirect(safeNext(req.query.next));
The regex is doing one job: the target must start with exactly one / followed
by something that is not a slash or a backslash. That rejects protocol-relative
URLs, backslash variants, and absolute URLs in one expression, and it fails
closed to a known default.
Better still: do not accept a destination at all. Where the set of post-login destinations is small, send a key and resolve it server-side:
const DESTINATIONS = {
billing: "/settings/billing",
invite: "/team/invitations",
default: "/dashboard",
};
res.redirect(DESTINATIONS[req.query.to] ?? DESTINATIONS.default);
Nothing the client sends reaches the Location header. There is no parser to
outsmart because there is no parsing.
When you genuinely need cross-origin redirects, compare origins with the platform URL parser, never with string operations:
const ALLOWED_ORIGINS = new Set([
"https://app.yourcompany.example",
"https://docs.yourcompany.example",
]);
function safeExternal(raw, fallback = "/dashboard") {
let url;
try {
// The base makes relative inputs resolve instead of throwing.
url = new URL(raw, "https://app.yourcompany.example");
} catch {
return fallback;
}
if (url.protocol !== "https:") return fallback;
if (url.username || url.password) return fallback; // closes the "@" bypass
if (!ALLOWED_ORIGINS.has(url.origin)) return fallback; // exact origin, not suffix
return url.href;
}
Four properties make this one hold: a real parser rather than a regex, an
exact-match allowlist of full origins (scheme, host, and port together) rather
than a suffix test, an explicit rejection of embedded credentials, and a
fallback on every failure path rather than a throw. Subdomain wildcards are
worth avoiding here for the same reason they are in
CORS: the moment *.yourcompany.example
is trusted, a forgotten staging host or a takeover-prone CNAME becomes an open
redirect on your main domain.
And when the destination really is arbitrary (a link shortener, an outbound link tracker, a webhook target list in a dashboard), do not redirect silently. Show an interstitial that names the destination and requires a click. That turns a phishing primitive into a warning, and it is what large platforms do with outbound links.
Two headers are worth setting alongside all of this. Referrer-Policy: strict-origin-when-cross-origin keeps your path and query out of the referrer
on any cross-site navigation, which removes the token leak even if a redirect
does escape. And on OAuth specifically, register exact redirect URIs and use
PKCE, so a stolen authorization code is not by itself usable.
Testing it
For every parameter named like a destination, try this list and confirm each one lands on your fallback rather than off-site:
?next=https://example.com/
?next=//example.com/
?next=/\example.com/
?next=https:/\example.com/
?next=https://yourapp.example.attacker.com/
?next=https://yourapp.example@example.com/
?next=%2f%2fexample.com%2f
?next=javascript:alert(1)
Then check the redirect chain, not just the first hop:
curl -sIL 'https://yourapp.example/login?next=//example.com/' | grep -i '^location'.
A redirect that goes to your own page and then bounces onward is the same bug
with an extra step, and it is invisible to a test that only reads one response.
Where it hides
The login next parameter is the one everybody eventually finds. The ones that
survive audits:
- Logout, which usually has its own
redirectand is written once and never reviewed. - Post-payment returns. Stripe Checkout
success_urlandcancel_urlare server-side and safe, but the internal handler you point them at often carries its ownnext. - Language and currency switchers, which redirect back to “where you came
from” and frequently trust the
Refererheader outright. - Email verification and password reset landings, which are the worst place for it because the URL carries a token.
- Client-side routers. A
router.push(searchParams.get("next"))or awindow.location = params.nexthas the identical bug with no server involved, and it addsjavascript:as a live payload. - Framework middleware. A Next.js middleware that builds a redirect from
request.nextUrl.searchParamsis the modern shape of all of the above.
What we flag
OpenGrep (app/engines/opengrep.py) matches the recognizable sinks: a request
value reaching res.redirect, a Location header built from a query
parameter, window.location assigned from searchParams. ESLint’s security
plugin (eslint_security.py) adds the non-literal cases on JavaScript projects.
The limit is the same one that applies to every taint-style rule: an engine can
see that a request value reaches a redirect, and it cannot judge whether the
validator between them is any good. startsWith looks like a check. Read the
check.
The principle
A redirect is your server vouching for a destination. Vouch only for destinations you chose. If the list of acceptable destinations is short, send a key instead of a URL; if it is your own app, send a path and prove it is one; and if it is genuinely the whole internet, say so on an interstitial rather than lending your domain to it quietly.