← Blog

Preventing an open redirect after login in Next.js: the ?redirect= check a tab character walked past

An open redirect after login in Next.js, found in our own sign-in page: a tab character slipped past the prefix check. How to parse ?redirect= safely.

The kit's sign-in page takes a ?redirect= parameter so the dashboard can send people back where they were after they log in. Any parameter like that is a candidate for an open redirect: a link on your real domain that, after a real login, drops the user on a page someone else controls, maybe one asking them to "confirm" their password. The kit had a guard against exactly this, and it looked complete. A tab character walked straight past it. I fixed it this week. Here is how to prevent an open redirect after login in Next.js properly, and why checking the string is the wrong approach.

The guard that looked right

// ?redirect= comes straight off the URL, so pin it to a same-origin path.
// Anything absolute ("https://…") or protocol-relative ("//…", "/\…") would
// turn the auth flow into an open redirect for phishing.
const safeRedirect =
  redirectTo.startsWith("/") &&
  !redirectTo.startsWith("//") &&
  !redirectTo.startsWith("/\\")
    ? redirectTo
    : "/dashboard";

// after a successful sign-in:
router.push(safeRedirect);

It blocks the obvious attacks: https://evil.com, //evil.com (protocol-relative, so it keeps the current scheme but changes the host) and /\evil.com, which browsers read the same as //evil.com. Read as a string, a value that starts with exactly one slash looks like a path on your own site.

How a tab gets past it

Send /sign-in?redirect=/%09/evil.com. Next.js decodes %09 before your page sees it, so the value is slash, tab, slash, evil.com. It starts with /, its second character is a tab rather than a slash, and the guard approves it.

Then router.push resolves the target the way the browser does, with new URL(href, location.href). The WHATWG URL parser, which browsers use for every address, removes all ASCII tabs and newlines from its input before it parses anything. The tab disappears, the value becomes //evil.com, and the result is a different host. Next.js 16 checks for exactly that. Its navigate reducer sees an external URL and calls completeHardNavigation, which ends in location.assign(...). The user leaves your site moments after typing their password into it.

I checked this on the live site with /\t/example.com instead of a hostile host. In headless Chromium, the old guard approved it, new URL resolved it to https://example.com/, and location.assign put the tab on https://example.com/. Testing more inputs against the old guard turned up four that leave the site: /\t/, /\n/, /\r\n/ and /\t\\, each followed by a host.

The page also had a second, smaller bug. It typed its search params as { redirect?: string }, but ?redirect=/a&redirect=/b arrives as an array. The form then called startsWith on an array, and the sign-in page returned a 500.

Parse it the way the browser will

The fix is to stop guessing how the browser will read the string and ask the same parser:

// src/lib/auth/redirect.ts
const BASE = "http://redirect.invalid";

export function safeRedirectPath(raw: unknown, fallback = "/dashboard"): string {
  if (typeof raw !== "string" || !raw.startsWith("/")) return fallback;
  try {
    const url = new URL(raw, BASE);
    if (url.origin !== BASE) return fallback;
    // Return the parser's normalized form, so what we navigate to is exactly
    // what we checked.
    return `${url.pathname}${url.search}${url.hash}`;
  } catch {
    return fallback;
  }
}

Resolving against a fixed base that cannot exist means any value that ends up on another origin, whatever trick got it there, fails the comparison. Returning the parser's own pathname, search and hash means the value you navigate to is the cleaned version you checked, with no tab left to reinterpret. The typeof check handles the array case. Both pages now type the param honestly and sanitize before rendering the form:

export default async function SignInPage({
  searchParams,
}: {
  // A repeated ?redirect= arrives as an array.
  searchParams: Promise<{ redirect?: string | string[] }>;
}) {
  const { redirect } = await searchParams;
  return <AuthForm mode="sign-in" redirectTo={safeRedirectPath(redirect)} />;
}

The form runs it again before router.push, so it stays safe if it is ever rendered from somewhere else.

To test it end to end, I ran a production build against a local Postgres and drove the real form in headless Chromium. Signing up with redirect=/%09/evil.com ended on /dashboard, and so did signing in with the newline variant. redirect=/pricing still ended on /pricing. The repeated parameter now renders the page instead of a 500.

Things that bite

  • Server redirects are no safer. If you pass the value to redirect() from next/navigation, the browser resolves the Location header with the same parser. Sanitize before any redirect, not only before router.push.
  • Encoded slashes are fine. /%2F%2Fevil.com passes the check and stays a path on your site, because %2F inside a path is not a separator. Do not decode the value a second time "to be safe". That is how a harmless string becomes a dangerous one.
  • An allowlist is stricter still. If you only ever send people back to a few routes, accept those and nothing else. The parser check is the right default for "any page on this site".
  • Type search params as the platform delivers them. string | string[] | undefined is the truth for every key. A narrower type compiles, then crashes on a URL someone edited by hand.

Shipwright, the Next.js and Claude starter kit this blog documents, now ships safeRedirectPath in its auth layer. The rest of the setup is in Better Auth in Next.js 16, and the cookie check that sends signed-out visitors here with ?redirect= in the first place is covered in the Next.js 16 proxy post.