All posts
VibeZero Team6 min read

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 redirect and is written once and never reviewed.
  • Post-payment returns. Stripe Checkout success_url and cancel_url are server-side and safe, but the internal handler you point them at often carries its own next.
  • Language and currency switchers, which redirect back to “where you came from” and frequently trust the Referer header 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 a window.location = params.next has the identical bug with no server involved, and it adds javascript: as a live payload.
  • Framework middleware. A Next.js middleware that builds a redirect from request.nextUrl.searchParams is 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.

ShareXLinkedIn