SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Open Redirect in Auth Callback
CWE-601Medium severityNext.jsSupabase

Open Redirect in Auth Callback

○SecureStartKit does not constrain redirect targets for you. Same-origin validation is your responsibility.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

Validate every user-supplied redirect target before using it. Accept only same-origin relative paths: the value must start with a single slash and must not start with a double slash or contain a scheme such as https. Anything else falls back to a safe default such as /.

Where it shows up: An auth callback or login route redirects to a user-supplied next or redirectTo parameter without checking that it is a same-origin relative path.

The vulnerable patterns and their fixes

Auth callback redirects to the raw next parameter

✗Vulnerablets
// app/auth/callback/route.ts  (VULNERABLE)
import { redirect } from 'next/navigation'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const next = searchParams.get('next') ?? '/dashboard'

  // ... exchange the auth code for a session ...

  // VULNERABLE: next is attacker-controlled.
  // /login?next=https://evil.example redirects off-domain after login.
  redirect(next)
}

The route passes the raw next value straight to redirect(). An attacker supplies an absolute or protocol-relative URL and the user is bounced off-domain immediately after a legitimate login.

↓the fix
✓Securets
// app/auth/callback/route.ts  (SECURE)
import { redirect } from 'next/navigation'

// Returns the value only if it is a safe same-origin relative path, else '/'
function sanitizeNext(value: string | null): string {
  if (!value) return '/'
  if (
    value.startsWith('/') &&
    !value.startsWith('//') &&
    !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)
  ) {
    return value
  }
  return '/'
}

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const next = sanitizeNext(searchParams.get('next'))

  // ... exchange the auth code for a session ...

  redirect(next)
}

sanitizeNext() rejects any value that is not a plain relative path: it must start with a single slash, must not be protocol-relative, and must not carry a scheme. The scheme check also blocks javascript: and data: URLs.

Protocol-relative URL bypasses a naive starts-with-slash check

✗Vulnerablets
// lib/auth-helpers.ts  (VULNERABLE)
export function safeRedirect(next: string | null): string {
  if (next && next.startsWith('/')) {
    // Looks safe because it starts with a slash.
    // But "//evil.example/phish" also starts with a slash.
    return next
  }
  return '/dashboard'
}

The guard only checks startsWith slash, so a protocol-relative value passes. Browsers interpret a leading double slash as protocol-relative, inheriting the page scheme and navigating off-origin.

↓the fix
✓Securets
// lib/auth-helpers.ts  (SECURE)
export function safeRedirect(
  next: string | null | undefined,
  fallback = '/dashboard'
): string {
  if (!next || typeof next !== 'string') return fallback
  const isRelative =
    next.startsWith('/') &&
    !next.startsWith('//') &&
    !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(next)
  return isRelative ? next : fallback
}

Two explicit rejections close the gap: the double-slash check blocks protocol-relative paths, and the scheme regex blocks absolute URLs regardless of scheme name. The function is pure and trivially unit-testable.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

An attacker crafts a login link that originates from your legitimate domain but carries a foreign redirect target:

/login?next=https://evil.example/phish

The victim clicks the link and logs in normally. Your callback route reads the next parameter and calls redirect(next) without validation. The browser follows the redirect to the attacker site. Because the journey started on your domain and the login was real, the victim has no obvious reason to distrust the destination. The attacker serves a cloned page and harvests credentials.

A subtler variant uses a protocol-relative URL:

/login?next=//evil.example/phish

A naive starts-with-slash check passes, because the value does start with a slash, but the browser treats the leading double slash as a protocol-relative reference and navigates off-origin.

In OAuth flows the risk compounds: if the authorization code or access token appears in the redirect URL, it leaks directly to the attacker server through the Referer header or server logs.

How to find it in your code

Search for redirect targets derived from user input:

grep -rn "redirect(searchParams" app/
grep -rn "get('next')" app/
grep -rn "get('redirectTo')" app/

For each hit, confirm the value passes through a same-origin check before it reaches redirect() or router.push(). Percent-encoded payloads count too: browsers decode them before navigation, so a check must run after decoding.

Manual test: append ?next=https://example.com to your login URL and complete a login. If you land on example.com, the redirect is unvalidated.

Common mistakes

  • Myth“Checking startsWith slash is enough to confirm same-origin.”

    A protocol-relative value that starts with two slashes also passes that check. You must additionally reject values that start with a double slash.

  • Myth“URL-encoding the attacker payload prevents exploitation.”

    Browsers decode percent-encoded characters before navigation. An encoded double slash decodes back to a protocol-relative path and still navigates off-origin.

  • Myth“This only matters for public login pages; internal tools are safe.”

    Internal tools are targeted specifically because employees trust them. A phishing link from your own SSO domain is far more convincing than an unknown domain.

  • Myth“Using the Next.js redirect() helper is safe because it is a server function.”

    redirect() is a thin wrapper around a 307/303 response. It redirects to exactly the string you pass. There is no built-in origin validation.

Does SecureStartKit prevent this?

The kit provides a working Supabase auth callback route, but if you add a next or redirectTo parameter to surface post-login redirects, validating that value against same-origin is your code to write. Use the sanitizeNext() pattern from the secure examples, or an equivalent safeRedirect() helper, in every place you read a redirect target from user input.

The kit auth flow→

Frequently asked questions

Does Supabase validate the redirect URL for me?
Supabase validates the Site URL and any additional Redirect URLs you configure in the dashboard for OAuth and magic-link callbacks. It does not validate the next parameter you pass around inside your own app after the callback completes. That part is your code.
Can I use new URL(next, origin) to validate instead of a regex?
Yes. Construct new URL(next, request.url) and compare the resulting origin against the request origin. If they match, the path is same-origin. Catch the constructor error for malformed inputs. This avoids regex edge cases at the cost of slightly more code.
What if I legitimately need to redirect to a different subdomain after login?
Build an explicit allow-list of trusted origins and check the parsed URL origin against it. Never accept arbitrary cross-origin redirects. The allow-list should be a constant in your config, not derived from user input.
Is this exploitable if login requires a password?
Yes. The victim enters their real credentials on your real login page. After successful authentication your server issues the redirect. The password requirement does not prevent the redirect from going off-domain.

References

  • CWE-601: URL Redirection to Untrusted Site ↗
  • OWASP Unvalidated Redirects and Forwards Cheat Sheet ↗

Related weaknesses

  • Next.js Middleware Auth BypassAuthorization is implemented only in middleware.ts or proxy.ts, with no second check at the data layer or inside the Server Action, route handler, or page component.
  • Unvalidated Server Action InputA Server Action reads FormData fields or typed arguments and passes them directly to a database query, or spreads them with the spread operator, without first running them through a Zod schema.
  • XSS via dangerouslySetInnerHTMLdangerouslySetInnerHTML is set from user-controlled HTML or from Markdown-to-HTML output without first running the string through a sanitizer such as DOMPurify.

Defined terms

  • OAuth
  • PKCE
  • Magic Link

Go deeper

  • Supabase OAuth, Magic Links, and MFA: The Secure Guide

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns