DEV Community

Cover image for 🔀 Open Redirects: Why Your ?next= Check Fails, and How to Prove It
Krishna Kumar
Krishna Kumar

Posted on

🔀 Open Redirects: Why Your ?next= Check Fails, and How to Prove It

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_uri point 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 as location.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 "/"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 keeps https: and replaces the host. It starts with /, so the first check waves it through.
  • /\evil.example lands on evil.example. For http and https URLs the standard treats a backslash like a forward slash (it flags a validation error, then carries on). Python's urlsplit sees 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 on evil.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 is evil.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 of evil.example.

Five redirect inputs that each pass a common server-side check but send the browser to evil.example, and the URL-parser fix that keeps all of them on app.example.com

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. Read the raw response first.
   curl -s -o /dev/null -D - "https://app.example.com/login?next=//evil.example/"
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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.
  2. 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:

  1. 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.
  2. If you accept a local path, use a framework helper that rejects non-local URLs. ASP.NET Core has LocalRedirect and Url.IsLocalUrl. Check what yours offers before writing your own.
  3. If you write your own, parse with the browser's rules and redirect to what you validated. In Node, that's the WHATWG URL class:
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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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

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)