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