Open redirects: why startsWith("/") is not enough
A "return to" parameter that only checks the first character can send your users to any site, from a link on your own domain. Why string checks fail, and the check that works.
By Tenovates3 min read
Written 2026-09 · TypeScript · WHATWG URL
The problem
Almost every site has a "return to" parameter: after signing in, go back to the page you came from.
/login?next=/account/orders
It is convenient, and it is a classic weakness. If next can point to
another site, anyone can build a link that starts on your domain —
trusted, familiar, maybe sent by email — and ends on a page they
control. That is an open redirect, and it is a favourite tool for
phishing.
So developers add a check. Usually this one:
export function naiveRedirect(next: string): string {
if (next.startsWith("/") && !next.startsWith("//")) return next;
return "/";
}
"Relative paths only, and no // (a protocol-relative URL to another
host)." It looks right. It isn't.
Why string checks fail
The check looks at the characters. The browser looks at the URL those characters become — and the URL parser is more forgiving than the check:
- Backslashes count as slashes. In a web URL,
\is treated like/, so/\evil.examplebecomes//evil.example. - Tabs and newlines are removed. The parser strips them before
anything else, so a path of slash, tab, slash (sent as
/%09/…in a link) also becomes//evil.example.
Neither input starts with //, so the naive check lets both through.
The browser then resolves them as protocol-relative URLs and leaves your
site.
This is the general lesson: validate a value by what it will do, not by how it looks.
The fix: resolve, then compare origins
Let the same URL parser the browser uses decide where the value leads, and accept it only if it stays on your origin:
const BASE = "https://same-origin.invalid";
export function safeRedirect(next: unknown, fallback = "/"): string {
if (typeof next !== "string") return fallback;
const value = next.trim();
// Relative paths only ; no control characters, no backslashes at all.
if (!value.startsWith("/") || /[\u0000-\u001f\u007f\\]/.test(value)) return fallback;
let url: URL;
try {
url = new URL(value, BASE);
} catch {
return fallback;
}
if (url.origin !== BASE) return fallback;
return url.pathname + url.search + url.hash;
}
Three things make it robust:
- Control characters and backslashes are refused outright — there is no legitimate reason for them in a return path.
- The value is resolved with the standard
URLparser against a placeholder origin, exactly as a browser would resolve it. - The origin is compared, and only the path, query and fragment are returned — never the raw input.
Use it everywhere a destination comes from outside: query strings, form
fields, cookies, OAuth state, even settings an administrator types in.
How to check
Test with the inputs that defeat string checks — and test the naive version too, as a negative control, to prove your test can tell the difference:
import { describe, expect, it } from "vitest";
import { naiveRedirect, safeRedirect } from "./redirect";
const attacks = ["//evil.example", "/\\evil.example", "/\t/evil.example", "https://evil.example"];
describe("safeRedirect", () => {
it.each(attacks)("refuses %j", (input) => {
expect(safeRedirect(input)).toBe("/");
});
it("keeps a real path, with query and fragment", () => {
expect(safeRedirect("/account/orders?page=2#latest")).toBe("/account/orders?page=2#latest");
});
});
describe("naiveRedirect (negative control)", () => {
it("lets at least one attack through", () => {
const leaked = attacks.filter((a) => new URL(naiveRedirect(a), "https://site.example").origin !== "https://site.example");
expect(leaked.length).toBeGreaterThan(0);
});
});
If the negative control passes — the naive check leaks — your test really exercises the dangerous inputs.
The take-away
A redirect target is safe when the parsed URL stays on your origin, not when the string starts with a slash. Resolve it the way the browser will, compare origins, and refuse anything odd.
Checklist
Redirect targets from outside
1. Find them : ?next= / ?returnTo= / callback URLs / OAuth state / admin link fields
2. One helper for all of them — no ad-hoc startsWith checks
3. Refuse control characters and backslashes
4. Resolve with URL against a fixed base, compare origin, return path + query + hash
5. Test : //, /\, slash-tab-slash, absolute URLs — plus a negative control