# Next.js open-redirect prevention — verify the auth callbackUrl / redirect()

> Passing user input straight to redirect() or callbackUrl lets a trusted own domain be the starting point to lure to an attacker site, leading to phishing or token theft right after authentication. It explains how to fall to the safe side with relative-path enforcement, a host allowlist, and verification with new URL, with vulnerable→fixed code of Next.js's auth flow, and bypass countermeasures like //evil and backslash.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Next.js, セキュリティ, TypeScript
- URL: https://tomodahinata.com/en/blog/nextjs-open-redirect-callback-url-prevention-guide
- Category: Application-layer security
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-supabase-application-security-guide

## Key points

- An open redirect is a flaw that makes you step on a URL of a trusted own domain and forwards to an attacker domain. Since the link looks like the own domain, it becomes a 'trust stepping stone' for phishing or token theft right after authentication.
- In Next.js, redirect(searchParams.next), the callbackUrl after login, the return destination of the OAuth callback, and middleware's NextResponse.redirect are typical occurrence sites. redirect() flies external too if you pass an absolute URL.
- A simple string check is broken — //evil.example (protocol-relative), /\evil (backslash), yourapp.com@evil.example (userinfo). The defense is to default to 'allow relative paths only' and collate the origin by resolving with new URL.
- If you absolutely fly external, use an explicit allowlist that collates the host by exact match and fix the scheme (https only). Trim the returned value to only the path part as much as possible.
- Honestly — the data flow of tainted-input→redirect-sink can be mechanically detected with taint analysis, but the design of 'where to allow transition to' can only be decided by a human who knows the business.

---

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](https://owasp.org/www-project-web-security-testing-guide/), the verification requirements of [OWASP ASVS](https://owasp.org/www-project-application-security-verification-standard/), and the input-validation discipline that [OWASP Top 10](https://owasp.org/www-project-top-ten/) 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.

```text
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](/blog/nextjs-supabase-application-security-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.**

```text
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](https://owasp.org/www-project-application-security-verification-standard/) 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.

```tsx
// 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.

```ts
// 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.

```ts
// 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.

```ts
// 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](/blog/nextjs-ssrf-prevention-server-actions-route-handlers-guide).

---

## 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.

```ts
// 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 origin `https://evil.example`, mismatching `base` → rejected.
- `/\evil.example` → `\` is normalized to `/`, equivalent to `//evil.example` → origin mismatch → rejected.
- `https://yourapp.com@evil.example` → the host is `evil.example` → origin mismatch → rejected.
- `/dashboard?tab=1` → the origin matches `base` → 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.**

```ts
// 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."

```ts
// 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));
}
```

```ts
// 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()` and `formData.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.

```ts
// 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.

```tsx
"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](/blog/nextjs-xss-dom-xss-dangerouslysetinnerhtml-prevention-guide).

---

## 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.

```bash
# 汚染入力→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`, or `cookies` straight through** to `redirect()` / `NextResponse.redirect()` / `router.push()`
- [ ] The default allows **only relative paths resolved with `new URL` and 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 `next` of the auth callback (`/auth/callback`, etc.)** (the most leak-prone route)
- [ ] You verify **the `callbackUrl` after 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's `redirect`. You don't pass a user URL to `rewrite`
- [ ] 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](/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](/aegis/audit). I myself, in the [lumber-distribution-industry DX project](/case-studies/lumber-industry-dx), 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)`, the `callbackUrl` after login, the OAuth callback's `next`, and middleware's `redirect`** are 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 URL` and 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.

---

## References

- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [OWASP Web Security Testing Guide (guidance for redirect inspection)](https://owasp.org/www-project-web-security-testing-guide/)
- [OWASP Application Security Verification Standard (ASVS / the allowlist verification requirement for allowed destinations)](https://owasp.org/www-project-application-security-verification-standard/)
