All resources
Note #010

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.example becomes //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:

  1. Control characters and backslashes are refused outright — there is no legitimate reason for them in a return path.
  2. The value is resolved with the standard URL parser against a placeholder origin, exactly as a browser would resolve it.
  3. 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