Next.js open redirect: why startsWith('/') fails
A Next.js route that redirects to a ?next=, returnTo or callbackUrl value from the URL is an open redirect: an attacker can send your users through your own domain to a phishing page. The usual guard, startsWith("/") && !startsWith("//"), doesn't stop /\evil.com. Parse the value against a fixed origin and accept it only if the origin doesn't change.
Every app with a login page ends up with a "take me back where I was" parameter. The middleware sends you to /login?next=/billing, you sign in, and the app redirects you to /billing. That convenience is also one of the most common findings in Next.js code: the destination comes from the URL, and anyone can write a URL.
What is an open redirect in Next.js?
An open redirect is any redirect() or NextResponse.redirect() whose destination an outsider controls. On its own it leaks no data. It's dangerous because it lends your domain's reputation to someone else's link:
https://yourapp.com/api/auth/logout?returnTo=https://yourapp-login.example
The victim checks the domain, sees yours, and clicks. Your server signs them out and forwards them to a pixel-perfect copy of your login page, which asks them to "sign in again". On an auth path, where people expect to bounce between pages, nobody notices the jump. Mail filters and link scanners that trust your domain let it through too. MITRE tracks it as CWE-601.
It gets worse when the redirect carries a credential. If the target URL picks up a token, a code or a session fragment on the way out, an open redirect turns from a phishing aid into account takeover. Supabase auth callbacks are the textbook case, covered in the PKCE code-verifier post.
The vulnerable pattern
Here's the most common shape: a logout or login route that forwards straight to a query parameter.
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: NextRequest) {
const supabase = await createClient();
await supabase.auth.signOut();
// Send the user back to wherever they came from.
return NextResponse.redirect(request.nextUrl.searchParams.get("returnTo") ?? "/");
}
- Warningapp/api/auth/logout/route.ts:9
Open redirect from request input
Never redirect straight to a user-supplied URL. Validate it against an allow-list of known paths/hosts first, or force it relative (only accept values starting with a single '/'). Note: this rule matches only the direct same-expression case — it does not track a tainted value across variables, so a laundered redirect can still be unsafe.
The same bug shows up in Server Actions (redirect(formData.get("next"))), in Server Components reading searchParams, and in proxy.ts / middleware.ts building a login redirect. The framework doesn't filter any of it: redirect() sends whatever string you hand it.
Why doesn't startsWith("/") stop an open redirect?
Because the browser doesn't read the string the way your check does. Most tutorials and AI assistants suggest some version of this guard:
const safe = next.startsWith("/") && !next.startsWith("//") ? next : "/";
return NextResponse.redirect(new URL(safe, request.url));
It blocks https://evil.com and the protocol-relative //evil.com. It doesn't block either of these:
| Input (decoded) | Passes the guard? | new URL(input, "https://app.example") |
|---|---|---|
/dashboard | yes | https://app.example/dashboard |
//evil.com | no | (blocked) |
/\evil.com | yes | https://evil.com/ |
/<TAB>/evil.com | yes | https://evil.com/ |
We ran those through Node's built-in URL, which implements the same WHATWG URL standard browsers use. For http and https URLs, the standard treats a backslash as a forward slash, so /\evil.com is read as //evil.com. It also strips ASCII tabs and newlines before parsing, so /%09/evil.com arrives as /<TAB>/evil.com once searchParams.get has decoded it, and collapses into //evil.com. The string check sees a single leading slash; the parser sees a different host.
Making the redirect relative doesn't help. The browser resolves a Location: /\evil.com header with the same parser and ends up on the same host.
One pattern does survive these inputs by accident. Supabase's own callback example builds the redirect as `${origin}${next}`. Prefixing the full origin means /\evil.com turns into the path https://app.example//evil.com instead of a new host. That's safe against these payloads, but only because of string concatenation. Change it to new URL(next, origin) while "cleaning up" and the bypass is back. Don't depend on that kind of accident.
How do I fix an open redirect in Next.js?
Let the URL parser make the decision. Resolve the input against a fixed, trusted origin. Accept it only if the result is still on that origin, and return just the path, query and hash:
// lib/safe-redirect.ts
import "server-only";
const BASE = "https://placeholder.invalid";
export function safeRedirectPath(raw: string | null | undefined, fallback = "/") {
if (!raw) return fallback;
try {
const url = new URL(raw, BASE);
if (url.origin !== BASE) return fallback; // //evil.com, /\evil.com, https://..., javascript:
return url.pathname + url.search + url.hash;
} catch {
return fallback;
}
}
Then build the final URL from your own origin:
const next = safeRedirectPath(request.nextUrl.searchParams.get("returnTo"));
return NextResponse.redirect(new URL(next, request.url));
Because the check uses the parser's verdict instead of guessing at it, every bypass in the table ends up at fallback. Run against the same inputs, it returns /dashboard and /settings?tab=1 unchanged and sends //evil.com, /\evil.com, /<TAB>/evil.com, https://evil.com and javascript:alert(1) to the fallback.
Three refinements are worth the extra minute:
- Use an allow-list when you can. If the only valid destinations are
/dashboard,/billingand/settings, compare against that list and skip the parsing. - Don't trust
x-forwarded-hostfor the origin. Userequest.urlor a configuredSITE_URL. A forwarded header is only as trustworthy as the proxy that set it. - Redirect to other domains only from a fixed list. If you hand off to a docs site or a billing portal, compare
url.hostnameagainst an exact set. Never useendsWith("yourapp.com"), whichevilyourapp.comalso passes.
What GuardLayer catches, and what it doesn't
GuardLayer's nextjs/open-redirect rule flags a redirect() or NextResponse.redirect() whose argument reads request input on the same call: searchParams.get(...), req.query or request.json(). That covers the logout route above and the classic auth callback.
It's precision-first, and that has a cost you should know about. The rule doesn't follow a value through variables, and it deliberately skips calls wrapped in new URL(...), because redirect(new URL("/path", request.url)) is the common safe form. So the bypassable startsWith guard in the previous section won't be flagged. Treat a clean scan as "no direct open redirect", not "every redirect is safe". Grep for the guard yourself (below), and send every redirect through a single helper like safeRedirectPath so there's one place to review. It's one of the app-layer checks that has to be done by hand.
Quick self-check
# 1. Redirects fed directly from the request (what the scanner sees)
grep -rnE "redirect\(.*(searchParams|req\.query|formData\.get)" app/ lib/
# 2. The bypassable guard (what it doesn't)
grep -rn "startsWith(\"/\")\|startsWith('/')" app/ lib/
# 3. Any redirect parameter names in use
grep -rnoE "(next|returnTo|redirectTo|callbackUrl|redirect_uri)\b" app/ | sort -u
Then try the payloads on your deployed app: ?next=//evil.com, ?next=/%5Cevil.com and ?next=/%09/evil.com. If any of them leaves your domain, you've found one.
FAQ
Is an open redirect a real vulnerability or just a low-severity finding? On its own it's usually rated low to medium, because it reads no data. It goes up when it sits on an auth path, or when the redirect forwards a code or token, because then it's a credential-theft step and not just a phishing aid.
Does Next.js protect redirect() against external URLs?
No. redirect() and NextResponse.redirect() send whatever destination you pass them, absolute URLs included. Validating it is your job.
Is encodeURIComponent enough?
No. Encoding is for safely putting a value into a URL. It doesn't decide where the URL is allowed to go, and searchParams.get decodes the value again before your code sees it.
What about callbackUrl in NextAuth / Auth.js?
Auth.js's default redirect callback only allows same-origin URLs. If you've overridden that callback to allow more, give it the same origin comparison shown above. For the Supabase version of this bug on the auth callback, see the PKCE code-verifier post.
Should I validate in proxy.ts instead?
Validate in the code that does the redirect. A proxy check is easy to miss when a new route is added, which is the same reason proxy.ts is the wrong place for auth.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.