Redirecting a user back to the page they were trying to reach after they log in is good, expected UX. Nobody wants to log in and land back on the homepage when they were actually trying to read a specific article or check out a specific product. The common way this gets implemented is also a common way to accidentally build an open redirect vulnerability directly into your auth flow.
The Pattern That Looks Completely Reasonable
// app/login/page.tsx
export default function LoginPage({ searchParams }: { searchParams: { callbackUrl?: string } }) {
return (
<form action={async (formData: FormData) => {
'use server';
const result = await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirect: false,
});
if (result?.ok) {
redirect(searchParams.callbackUrl || '/dashboard');
}
}}>
{/* form fields */}
</form>
);
}
This is a genuinely common pattern, a protected page redirects an unauthenticated visitor to /login?callbackUrl=/dashboard/reports, and after a successful login, the app sends them back to wherever that parameter points. It works correctly, and it feels like a small, harmless convenience feature, right up until someone realizes that callbackUrl value came straight from a query string the visitor controls entirely.
What an Attacker Actually Does With This
https://yourapp.com/login?callbackUrl=https://evi1-yourapp-login.com/phish
If your redirect logic trusts the callbackUrl value without validating that it actually points somewhere inside your own app, a victim who clicks a link like this, maybe from a phishing email that looks legitimate because the domain in the visible link is genuinely yourapp.com, logs in successfully on your real site, with their real credentials, and then gets silently redirected to an attacker-controlled page immediately afterward. That page can look like anything, a fake "session expired, please log in again" prompt designed to harvest the same credentials a second time, now with the victim's guard completely down because they just successfully authenticated on the real site seconds earlier.
This is specifically dangerous because every part of the actual login happened on your real, legitimate domain. Nothing about the login page itself looked suspicious, because it wasn't, the vulnerability is entirely in what happens after a genuine, successful login.
Why This Is Easy to Miss
Redirect-after-login code gets tested by developers clicking through their own app, which only ever produces callbackUrl values that point to real internal pages, because that's the only way anyone naturally encounters this flow during development. The idea of someone deliberately crafting a malicious external value for this parameter doesn't come up unless security testing specifically targets it, since from a pure functionality standpoint, the feature works perfectly for every normal case anyone would think to try.
The Fix: Validate the Redirect Target Is Actually Internal
// lib/safe-redirect.ts
export function getSafeRedirectUrl(callbackUrl: string | undefined, fallback = '/dashboard'): string {
if (!callbackUrl) return fallback;
// Reject anything that isn't a relative path starting with exactly one slash.
// "//evil.com" and "https://evil.com" both get rejected here,
// protocol-relative URLs are a classic bypass for a naive check.
if (!callbackUrl.startsWith('/') || callbackUrl.startsWith('//')) {
return fallback;
}
return callbackUrl;
}
if (result?.ok) {
redirect(getSafeRedirectUrl(searchParams.callbackUrl));
}
The specific check for a leading // matters more than it looks like it should. A browser interprets //evil.com as a protocol-relative URL, meaning it resolves to https://evil.com using whatever protocol the current page is on, and a naive check that only verifies "does this start with a single slash" can be bypassed entirely by that exact pattern. Rejecting anything that doesn't match a genuine single-slash-prefixed relative path closes this specific gap.
If you're using NextAuth's built-in redirect callback, the same principle applies there directly, NextAuth exposes a redirect callback specifically so you can validate target URLs centrally:
// auth.ts
export const authOptions: NextAuthOptions = {
callbacks: {
async redirect({ url, baseUrl }) {
// Only allow redirects to the same origin as the app itself
if (url.startsWith(baseUrl)) return url;
if (url.startsWith('/')) return `${baseUrl}${url}`;
return baseUrl;
},
},
};
The Rule
Any redirect target sourced from user-controllable input, a query parameter, a form field, a referrer header, needs to be validated as pointing somewhere inside your own app before you redirect to it. "It's just a convenience redirect" is exactly the framing that makes this vulnerability easy to introduce without noticing, since the feature's happy path looks completely benign and only the deliberately crafted input reveals the gap.
I validate every redirect target this way in the auth flows across my Next.js dashboard templates, since login and post-auth redirects show up in basically every project and this is a genuinely easy detail to skip under normal development pressure. More on the broader auth patterns I build around at pixelanas.com.
Get the templates: https://pixelanas.gumroad.com
Go check your own login flow for a callbackUrl, returnTo, or next parameter that gets redirected to without validation. If you find one, try crafting a //evil.com style value against it. Drop what you find in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)