Almost every login flow has one: /login?next=/billing. After sign-in you send the user back to wherever they were. And almost every first version of the check looks like this:
const ok = next.startsWith('/') && !next.startsWith('//');
It reads as airtight. It isn't. The problem is that your string check and the browser's URL parser disagree about what the string means.
Why the check fails
The WHATWG URL Standard tells browsers to strip ASCII tabs and newlines from the input before parsing. It also treats \ like / for special schemes such as https. So /\t/evil.com becomes //evil.com by the time it's parsed, which is a protocol-relative URL pointing at another host. /\evil.com gets the same treatment.
The query-string part matters too. When someone sends ?next=/%09/evil.com, searchParams.get('next') hands your code the decoded value, "/\t/evil.com". Your startsWith('/') check passes, and the browser ends up on evil.com.
I ran 10 strings through two checks
Here's a small Node script (Node v24). Check (a) is the naive one above. Check (b) parses the value against your own origin and only accepts it if the origin still matches:
const ORIGIN = 'https://example.app';
function safeNext(next) {
try {
const u = new URL(next, ORIGIN);
return u.origin === ORIGIN ? u.pathname + u.search + u.hash : null;
} catch {
return null;
}
}
The last column shows where the browser actually ends up: new URL(value, origin), plus what happens if the value gets decoded one more time.
| Input | Naive check |
new URL + origin check |
Resolves to |
|---|---|---|---|
/%09/evil.com (raw) |
ALLOW | /%09/evil.com |
example.app (but evil.com once decoded) |
//evil.com |
block | block | evil.com |
/\evil.com |
ALLOW | block | evil.com |
\\evil.com |
block | block | evil.com |
/<TAB>/evil.com |
ALLOW | block | evil.com |
https:evil.com |
block | /evil.com |
example.app/evil.com |
/%2F%2Fevil.com (raw) |
ALLOW | /%2F%2Fevil.com |
example.app (but evil.com once decoded) |
///evil.com |
block | block | evil.com |
/.evil.com |
ALLOW | /.evil.com |
example.app/.evil.com (harmless) |
javascript:alert(1) |
block | block |
javascript: URL |
And the real-world case from a query string:
searchParams.get: "/\t/evil.com" -> https://evil.com/
Three takeaways:
-
Two strings the naive check lets through really leave your site:
/\evil.comand a tab-prefixed path. A tab is exactly what?next=/%09/evil.combecomes after normal query decoding. -
The percent-encoded variants are only safe if nothing decodes them again.
new URLkeeps/%09/evil.comsame-origin, but one moredecodeURIComponentanywhere in your pipeline (a middleware, a "clean up" helper, a second redirect hop) turns it into evil.com. Validate the final value, then redirect with exactly what you validated. -
Redirect to the normalized path, not the raw input. Return
u.pathname + u.search + u.hashfrom the parsed URL, not the original string.
This bug has shipped in real auth tools
oauth2-proxy had exactly this class of bug. The GHSA-j7px-6hwj-hpjg advisory (CVE-2020-11053) says a redirect URL with encoded whitespace characters (%0a, %0b, %09, %0d) could bypass its validation and redirect to any URL.
It's also a classic subject line for unsolicited "bug bounty" emails. If one lands in your inbox, run the 10 strings above against your own check before you reply, and here's how I'd tell a real security report from a beg bounty.
If you're doing OAuth, the stakes are higher. RFC 9700 (OAuth 2.0 Security Best Current Practice) covers open redirectors in section 4.11 and requires exact string matching of redirect URIs in section 2.1, because a loose match plus an open redirect on the client can leak authorization codes.
Framework notes
-
Next.js:
redirect()sends whatever you give it. It doesn't check that the URL is on your origin, so runsafeNext()first. -
Auth.js: the
redirectcallback is where you control this. If you customize it, keep the "relative or same-origin only" logic rather than returningurlas-is.
The checklist
- Parse with
new URL(next, yourOrigin)and compareorigin. Don't use regex orstartsWith. - Fall back to a fixed path (like
/dashboard) when the check fails. - Decode once, validate once, and redirect with the value you validated.
- Better still, use an allowlist of known paths. The OWASP Unvalidated Redirects and Forwards Cheat Sheet recommends mapping to server-side values where you can.
- Add those 10 strings to your test suite. It takes five minutes.
The same idea applies to the other end of a login flow. A ?next= check stops an attacker sending your user somewhere else, and the support inbox has its own version of that, which I wrote up in why "please change my login email" is the riskiest ticket you'll get.
What does your ?next= check look like? I'd honestly like to see the variants people ship.
Top comments (0)