Let me state the conclusion first. An open redirect is a hole that opens when you pass user input straight to redirect() or callbackUrl, and the essence of the countermeasure comes down to "don't trust the transition destination as a string." The safe-side default is "allow only relative paths of your own origin." Only on routes with a legitimate reason to fly external, exceptionally allow it with a host-exact-match allowlist and a fixed scheme — with this two-stage setup, you can mechanically crush it.
This isn't a difficult attack. Just write ?next=https://evil.example in the URL query. Nevertheless, the reason it easily remains in production is that it never surfaces as long as you touch it with your own account. Both the developer and the AI build the happy path of "return to the original page after login," but "what if the return destination has an attacker URL inserted" no one tries in a demo. This article explains where that oversight opens, why a naive check can't plug it, and how to verify to fall to the safe side, with real code (vulnerable→fixed) of Next.js's auth flow. OWASP has long organized this as "Unvalidated Redirects and Forwards," and it's now positioned in the inspection items of the OWASP Web Security Testing Guide, the verification requirements of OWASP ASVS, and the input-validation discipline that OWASP Top 10 preaches.
1. What an open redirect is — a forward that makes a trusted domain a "stepping stone"
An open redirect is a flaw where the app receives a value the user can manipulate as the transition destination and forwards to there without verification. The link that becomes the attack's starting point takes this form.
https://yourapp.com/login?next=https://evil.example/login
^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
trusted own domain the actual landing site (attacker)
What the user sees in an email or on social media is the legitimate domain yourapp.com. Even hovering, the domain is the own one. The email's spam filter and URL inspection let it through too, because it's addressed to the own domain. But when accessed, the app trusts the next parameter and forwards to https://evil.example/login. The domain's trust is lent wholesale to the attacker — this is the essence of an open redirect.
What's important is that this request is completely normal as HTTP. It contains no invalid string or injection. Only the fact that next's value "is an external host" is the problem, and whether it's an attack is decided solely by the app-specific rule "which transition destinations this app allows." So it structurally can't be stopped by a WAF or security headers, and code-side verification is needed. This structure of "tainted input → dangerous sink" is common to the whole injection class; the overall picture is mapped in the Next.js × Supabase application-security complete guide.
2. Why it's dangerous — phishing, and token theft right after authentication
An open redirect alone doesn't "take over the server." What's scary is that it becomes a "trust stepping stone" that makes other attacks hold. The damage is broadly in two lines.
2-1. Phishing — lending out the domain's trust
The attacker distributes https://yourapp.com/login?next=https://evil.example/login. The user trusts the own domain and clicks, and instantly lands on an indistinguishable fake login screen (evil.example). The credentials and one-time code entered there are stolen as-is. The first step being the legitimate domain breaks through both the filter and the user's caution.
2-2. Token theft right after authentication — the return destination of OAuth/callbacks
More serious is when it involves the auth flow. In OAuth/OIDC, the authorization code or token the authorization server issues is delivered to the registered return destination (redirect_uri). Normally, strict registration of redirect_uri prevents sending to an arbitrary host, but if there's an open redirect on an own host on the allowlist, it gets chained.
authorization server → https://yourapp.com/callback?next=https://evil.example
(passes because it's a registered own host)
yourapp.com/callback → trusts next and forwards to evil.example
→ the authorization code/token leaks via query, fragment, or Referer
The situation is the same with the app's own callbackUrl (the value to return the user to the original screen after login). Since the user is sent to the attacker site right after authentication succeeds, when they let their guard down most, it's abused for two-stage impersonation and session takeover. The reason OWASP ASVS demands as a verification requirement "redirect only to allowed URLs (allowlist), or show a warning for untrusted destinations" is exactly to sever this chain.
3. The 4 places an open redirect opens in Next.js
In Next.js, there are several APIs that handle the transition destination. next/navigation's redirect(), a Route Handler's NextResponse.redirect(), middleware's NextResponse.redirect(), and the client's router.push() / window.location. Any of them becomes a hole if you pass the user's input straight through. Let me list four typicals with vulnerable code.
3-1. redirect(searchParams.next) in a Server Component
next/navigation's redirect() flies external too if you pass an absolute URL. Next.js doesn't restrict this. The responsibility for safety lies completely with the caller.
// app/login/page.tsx — 脆弱:next をそのまま redirect に渡す
import { redirect } from "next/navigation";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string }>;
}) {
const { next } = await searchParams; // ← クライアントが自由に変えられる汚染入力
const session = await getSession();
if (session) {
redirect(next ?? "/dashboard"); // next="https://evil.example" でも素直に飛ぶ
}
// …ログインフォームを表示
}
3-2. The callbackUrl of a login Server Action
A "use server" action is effectively a POST endpoint, and formData's values are equally client-manipulable. "It's safe because it's a hidden field not shown on screen" doesn't hold.
// app/login/actions.ts — 脆弱:callbackUrl を検証せず認証直後に飛ばす
"use server";
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
const callbackUrl = String(formData.get("callbackUrl") ?? "/dashboard");
await signIn(formData); // 認証成功
redirect(callbackUrl); // ← 最も信頼された瞬間に攻撃者サイトへ送られうる
}
3-3. The Route Handler of an OAuth/auth callback
app/auth/callback/route.ts, which receives the return from Supabase or Auth.js, is a standard that receives next and forwards after establishing the session. This is the most-stepped-on.
// app/auth/callback/route.ts — 脆弱:戻り先を new URL に素通し
import { NextResponse, type NextRequest } from "next/server";
export async function GET(req: NextRequest) {
const next = req.nextUrl.searchParams.get("next") ?? "/";
await exchangeCodeForSession(req); // セッション確立
return NextResponse.redirect(new URL(next, req.nextUrl.origin));
// next="//evil.example" → new URL は https://evil.example に解決される
}
Since NextResponse.redirect() requires an absolute URL, you tend to assemble it with new URL(next, origin), but new URL overrides the second argument's base if the first argument is an absolute URL or protocol-relative. This is the hotbed of the next section's bypass.
3-4. middleware's NextResponse.redirect()
When you reject the unauthenticated and send them to login "with a return destination," and reuse that return destination (from), the same hole opens.
// middleware.ts — 脆弱:戻り先 from を検証せず redirect
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const from = req.nextUrl.searchParams.get("from");
if (from) return NextResponse.redirect(new URL(from, req.url)); // 検証なし
// …
}
Note that passing a user-controlled URL to middleware's NextResponse.rewrite() turns into SSRF, not an open redirect (your own edge reaching an internal resource). This is a separate-class problem, handled in Next.js SSRF prevention.
4. Why a simple check is broken — the system of bypasses
"If it doesn't start with http, it's probably a relative path," "if it contains the own domain, OK" — such naive string-based checks are all circumvented. Let me organize the representative bypasses (the attacker domain is evil.example, the own one is yourapp.com).
| Input example | A naive check's judgment | The actual interpretation of the browser/URL parser |
|---|---|---|
/dashboard | passes (safe) | a path of the own origin (legitimate) |
//evil.example | passes startsWith("/") | protocol-relative → https://evil.example |
/\evil.example (backslash) | evades startsWith("//") | in http(s)-type, \ is treated as / → //evil.example |
https://yourapp.com@evil.example | contains the own name in the string | before @ is userinfo, the host is evil.example |
https://yourapp.com.evil.example | passes startsWith("https://yourapp.com") | the host is yourapp.com.evil.example |
https://evil.example/yourapp.com | passes includes("yourapp.com") | the host is evil.example |
javascript:alert(document.cookie) | passes without scheme verification | executable if it's a client transition |
The core of the trap is two.
One is // and \. //evil.example is a "protocol-relative URL" omitting the scheme, and the browser supplements the current scheme and treats it as https://evil.example. The startsWith("/") check passes it. Even more troublesome is the backslash (\). In the WHATWG URL standard, in a special scheme like http/https, \ is normalized the same as /, so /\evil.example becomes equivalent to //evil.example and slips past the countermeasure startsWith("//").
The other is @ (userinfo) and subdomain spoofing. https://yourapp.com@evil.example has user info before @ and the host is evil.example. A check that looks at whether the string "contains" the own domain is wiped out. Unless you look at the host by exact match, yourapp.com.evil.example and evil.example/yourapp.com also pass.
The conclusion is this. The transition destination must be judged not by "the string" but by "the structure (origin/host/scheme)." And the tool that can interpret that structure with the same rules as the browser is new URL.
5. The correct countermeasure — relative-path enforcement, new URL verification, host allowlist
5-1. Default to allowing only "relative paths of your own origin"
Most post-login transitions are just returning to a path inside your own app. Then "discard everything except relative paths of your own origin" is the most robust and simplest. The key is to resolve with the same parser as the browser (new URL) and look only at whether the origin matches. Don't use string prefix matching at all.
// lib/safe-redirect.ts — 自オリジン上の相対パスだけを許可する(既定の防御)
export function toSafeRelativePath(
input: string | null | undefined,
fallback = "/dashboard",
): string {
if (!input) return fallback;
// 実在しない予約TLDを基準オリジンにする。許可ホストと偶然一致しないため安全。
const base = "https://placeholder.invalid";
let url: URL;
try {
url = new URL(input, base); // ブラウザと同じ規則で解決(\→/ 正規化等もここで吸収)
} catch {
return fallback; // パース不能な値は捨てる
}
// 別ホストに化けたら拒否。これ1つで //evil・/\evil・@evil・絶対URL を全部弾く。
if (url.origin !== base) return fallback;
// 万一に備え、絶対URLは絶対に返さない。パス成分だけに削る。
return url.pathname + url.search + url.hash;
}
Let me confirm why this wipes out the previous section's bypasses.
//evil.example→ resolving it makes the originhttps://evil.example, mismatchingbase→ rejected./\evil.example→\is normalized to/, equivalent to//evil.example→ origin mismatch → rejected.https://yourapp.com@evil.example→ the host isevil.example→ origin mismatch → rejected./dashboard?tab=1→ the origin matchesbase→ returns"/dashboard?tab=1"(legitimate).
The crux of this design is delegating "the judgment of the allowed host" to the URL parser rather than writing it in your own string processing. Furthermore, by returning only pathname + search + hash at the end, even if an input that slips past the origin judgment appears in the future, it severs the very route by which an absolute URL gets mixed into the output (defense-in-depth).
5-2. If there's a legitimate reason to fly external, a host-exact-match allowlist
There are requirements to really redirect external, like returning to a payment provider or an affiliated domain. Only in that case, allow the exception with an explicit allowlist. Here too the judgment is made against the structure, the host by exact match, and the scheme fixed to https.
// lib/safe-redirect.ts — 外部遷移は明示的 allowlist でのみ許可する
const ALLOWED_HOSTS = new Set([
"app.example-corp.com",
"docs.example-corp.com",
]);
export function toAllowlistedUrl(
input: string | null | undefined,
fallback = "/",
): string {
if (!input) return fallback;
let url: URL;
try {
url = new URL(input); // 絶対URL必須。相対は throw → fallback
} catch {
return fallback;
}
if (url.protocol !== "https:") return fallback; // スキームを固定(javascript: 等を排除)
if (!ALLOWED_HOSTS.has(url.hostname.toLowerCase())) return fallback; // host を完全一致で照合
return url.toString();
}
The point is to lowercase url.hostname and exact-match it against the Set. Don't use suffix matching like endsWith(".example-corp.com"), because it passes evilexample-corp.com. If you also allow subdomains, anchor with a leading dot like hostname === "example-corp.com" || hostname.endsWith(".example-corp.com").
5-3. Apply it to each Next.js route
Once you've prepared the verification function, the vulnerable code of section 3 is plugged just by "verifying before flying."
// app/auth/callback/route.ts — 修正:検証してから redirect
import { NextResponse, type NextRequest } from "next/server";
import { toSafeRelativePath } from "@/lib/safe-redirect";
export async function GET(req: NextRequest) {
const next = toSafeRelativePath(req.nextUrl.searchParams.get("next"));
await exchangeCodeForSession(req);
// next は同一オリジンの相対パスであることが保証されているので new URL も安全
return NextResponse.redirect(new URL(next, req.nextUrl.origin));
}
// app/login/actions.ts — 修正:callbackUrl を相対パスに正規化してから redirect
"use server";
import { redirect } from "next/navigation";
import { toSafeRelativePath } from "@/lib/safe-redirect";
export async function login(formData: FormData) {
const callbackUrl = toSafeRelativePath(formData.get("callbackUrl")?.toString());
await signIn(formData);
redirect(callbackUrl); // 自オリジンのパスにしか飛ばない
}
For Server Components and middleware too, similarly just pass it through toSafeRelativePath() right after extracting it from searchParams. Verify once "at the place you received the input," right before the transition — this is the discipline.
Beware of encoding handling.
searchParams.get()andformData.get()return already percent-decoded values. So%2f%2fevil(=//evil) is decoded at this point, and is correctly rejected by the above verification. Conversely, if you reuse the pre-decode raw string elsewhere, double-decoding causes a discrepancy, so verify "the value extracted from the parsed API" and use it as-is — that's safe.
5-4. Fix the bypass inputs as regression tests
Don't make the verification function "write it once and done." If you fix the previous section's bypass inputs as tests as-is, the moment someone (human or AI) loosens the verification with "it's only a relative path," CI goes red and stops the regression. The new URL-based verification is a side-effect-free pure function, so you can test it cheaply and fast with vitest.
// lib/safe-redirect.test.ts — バイパス入力が確実に弾かれることを固定する
import { describe, expect, it } from "vitest";
import { toSafeRelativePath } from "./safe-redirect";
describe("toSafeRelativePath", () => {
it("自オリジンの相対パスは通す", () => {
expect(toSafeRelativePath("/dashboard?tab=1")).toBe("/dashboard?tab=1");
});
// 第4節のバイパス表を、そのまま実行可能な仕様として固定する
it.each([
"//evil.example", // プロトコル相対
"/\\evil.example", // バックスラッシュ(\→/ 正規化で //evil 相当)
"https://yourapp.com@evil.example", // userinfo 詐称
"https://yourapp.com.evil.example", // サブドメイン詐称
"javascript:alert(document.cookie)", // 危険スキーム
])("バイパス入力 %s は fallback に倒す", (input) => {
expect(toSafeRelativePath(input)).toBe("/dashboard");
});
it("空・null・undefined は fallback に倒す", () => {
expect(toSafeRelativePath(null)).toBe("/dashboard");
expect(toSafeRelativePath("")).toBe("/dashboard");
expect(toSafeRelativePath(undefined)).toBe("/dashboard");
});
});
If the test is green, it becomes mechanical evidence that "the common bypasses are rejected." But — this isn't proof that "the allowed-destination design is correct" (section 7). What the test protects is "it isn't broken," not "the spec is correct." Keep this distinction in mind to the end.
6. The additional risk of client transitions — javascript: and location
In a server-side HTTP redirect (the Location header), the javascript: scheme isn't executed. But in a client-side transition — window.location.href = next, router.push(next), <a href={next}> — the story changes.
"use client";
// 脆弱:javascript: / data: スキームがそのまま実行されうる(DOM XSS)
function goBack(next: string) {
window.location.href = next; // next="javascript:alert(document.cookie)" で発火
}
In other words, a client transition bears the double risk of an open redirect (forward to an external host) plus script execution via a scheme (DOM XSS). The countermeasure is likewise to normalize to a relative path with toSafeRelativePath() before passing. When allowing an external URL too, reliably exclude schemes other than https: with toAllowlistedUrl(). This execution of the javascript: / data: scheme is essentially a kind of DOM XSS, and its mechanism and countermeasures are dug into in detail in DOM XSS and dangerouslySetInnerHTML countermeasures.
7. Detect with taint — tainted input → redirect sink
An open redirect has the common structure that "a client-manipulable input (source) reaches a transition sink (sink) without being verified." So you can follow it mechanically with intra-function data flow (taint analysis), not regex.
| Tainted input (source) | Dangerous sink (sink) |
|---|---|
searchParams.get("next") | redirect(next) |
params / formData.get("callbackUrl") | NextResponse.redirect(new URL(next, …)) |
req.nextUrl.searchParams.get("from") | router.push(next) / window.location = next |
cookies().get("returnTo") | <a href={next}> |
The OSS Aegis I publish detects this "source → (without verification) sink" path. It runs with no installation, no config.
# 汚染入力→redirectシンクのデータフローを可視化する
npx @aegiskit/cli scan
The detection logic looks at whether a "verification node" like resolving with new URL + origin collation, or allowlist collation, is interposed on the path from a tainted source like searchParams.get to a redirect-family sink. If not interposed, it points it out as an "unverified redirect."
The honest scope. What taint analysis can mechanically find is only the fact that "input reaches a transition sink without verification." "Which transition destination should be allowed" — the host to put on the allowlist, the business-correct answer of where to return after login — can't be decided by a tool. That's your app's spec, a design judgment that can't be derived from the code's external form. Data-flow analysis is intra-function (intraprocedural) in principle and misses flows crossing modules or frameworks. A clean result is "you haven't stepped on the common traps," not "safe." This detection isn't a replacement for human review and threat modeling but a complement.
8. Pre-production checklist
Whether outsourced or AI-made, confirm at least this before going to production.
- You don't pass values from
searchParams,formData, orcookiesstraight through toredirect()/NextResponse.redirect()/router.push() - The default allows only relative paths resolved with
new URLand origin-collated (not a prefix-match check) - External transition is allowed only with a host-exact-match allowlist + a fixed scheme (https)
- You actually tried the bypass inputs
//evil,/\evil,yourapp.com@evil,yourapp.com.evil(does it stay on the own site with 200) - You verify the
nextof the auth callback (/auth/callback, etc.) (the most leak-prone route) - You verify the
callbackUrlafter login (the most dangerous transition right after authentication) - In client transitions, you exclude the
javascript:/data:scheme (DOM XSS countermeasure) - You verify the return destination (
from) of middleware'sredirect. You don't pass a user URL torewrite - You permanently station a taint scan (
npx @aegiskit/cli scan) in CI and stop the re-mixing-in of an unverified redirect
What's most effective from the client's viewpoint is the two questions "If I attach ?next=//evil.example, where does it fly?" "How do you verify the post-login return destination?" A good developer can answer immediately.
9. How far yourself, and where audit begins
Let me draw the line honestly.
Up to "finding an unverified redirect" can be mechanically crushed by automation. If you put taint analysis that follows the path from tainted input to a transition sink into CI, and consolidate verification functions like toSafeRelativePath() / toAllowlistedUrl() in one place, a human doesn't need to think every time. First visualizing the current state with Aegis (free OSS, npx @aegiskit/cli scan) is the most cost-effective first step.
On the other hand, the design of "where to allow transition to" is the human domain. The host to put on the allowlist, the correct return destination after authentication, the recovery flow from an external payment — these can only be decided by a human who understands your business and data model. A tool that asserts "introduce it and it's safe" here is rather dangerous. Aegis detects/warns about an unverified redirect, but doesn't prove that the allowed-destination design is correct. There's no magic to make it completely safe.
That's exactly why a line is needed. How far to firm up yourself, and where an expert's review is needed — if you need a design review of the auth flow or redirects in an existing Next.js app, I take it on with a security audit. I myself, in the lumber-distribution-industry DX project, designed and verified application-layer authorization including authentication, tenant isolation, and transition control in actual operation.
Frequently asked questions (FAQ)
Q. If I only pass a relative path to redirect(), is verification unnecessary?
A. If that value is a constant, it's unnecessary. The problem is only when you pass a dynamic value from searchParams or formData. A dynamic value, even if it looks like a short relative path, turns into //evil or /\evil, so always pass it through toSafeRelativePath().
Q. Can't I write the allowlist with a regex?
A. You should avoid it. A regex like /yourapp\.com/ also matches yourapp.com.evil.example. Extracting hostname with new URL and exact-matching with a Set is safe. If you allow subdomains, anchor with a leading dot.
Q. Is it enough to just reject protocol-relative (//)?
A. It's insufficient. A backslash (/\evil) becomes equivalent to //evil by \→/ normalization and slips past startsWith("//"). Furthermore, there's @ (userinfo) and subdomain spoofing too. Rather than crushing individual patterns, it's solid to lean on the one judgment of resolving with new URL and collating the origin.
Q. If I use Auth.js (NextAuth) or Supabase, is it automatically safe?
A. The framework's default redirect callback in many cases allows only same-origin/relative. But if you return a raw value in a custom redirect callback, or forward the next of /auth/callback yourself, the hole returns at that moment. Don't leave it to the framework; always verify the transition routes you wrote yourself.
Q. Can an open redirect be stopped by a WAF?
A. It can't. ?next=https://evil.example is a "legitimate request" correct in both authentication and format, and whether it's an attack is decided solely by the app-specific rule "which transition destinations this app allows." The WAF doesn't know your allow policy. Code-side verification is the only defense.
Summary: judge the transition destination not by "the string" but by "the structure"
Let me organize the key points.
- An open redirect is a flaw that forwards to an attacker domain with a trusted own domain as the starting point. It becomes a stepping stone for phishing and token theft right after authentication.
- In Next.js,
redirect(searchParams.next), thecallbackUrlafter login, the OAuth callback'snext, and middleware'sredirectare typical occurrence sites.redirect()flies external too if you pass an absolute URL. - A naive string check is broken —
//evil(protocol-relative),/\evil(backslash),yourapp.com@evil(userinfo),yourapp.com.evil(subdomain spoofing). Judge the transition destination not by the string but by the structure (origin/host/scheme). - The defense default is "allow only relative paths resolved with
new URLand origin-collated." External transition is exceptionally allowed with a host-exact-match allowlist + fixed https. Trim the returned value to only the path part. - The data flow of tainted-input→redirect-sink can be mechanically detected with taint analysis (
npx @aegiskit/cli scan). But the allowed-destination design is a human judgment, and a tool only helps detection and doesn't prove correctness.
Building fast with AI is itself correct. Firming up what you built fast, safely, without leaks — if you need to build that mechanism, or a review of an existing Next.js app's auth flow and redirect design, please feel free to consult me.