A quick guide to open redirect vulnerabilities — and why your returnUrl parameter needs a babysitter.
You ship a login page. Users hit a protected route, get bounced to /login, and after authenticating they land back where they started. Classic UX pattern. Every framework tutorial teaches it.
What they don't teach you is that you just built a phishing accelerator.
The Problem in 30 Seconds
Most login flows pass the "where to go after login" destination as a query parameter:
/login?returnUrl=/account/orders
After successful auth, your code does something like:
const returnUrl = new URL(window.location).searchParams.get('returnUrl');
window.location.href = returnUrl || '/account';
Looks fine. Works great. Ships to prod.
Now an attacker sends your users this link:
/login?returnUrl=https://your-site-login.evil.com
The user sees your domain in the URL bar. They trust it. They log in. Then your own code redirects them to a pixel-perfect clone of your site that harvests their credentials, payment info, or whatever else the attacker wants.
This is an open redirect, and it's been on the OWASP Top 10 list for years. It's trivial to exploit, trivial to fix, and still everywhere.
Why This Catches People Off Guard
A few reasons this slips through:
- It doesn't look like a vulnerability. There's no SQL, no script injection, no weird characters. It's just a URL.
- It works perfectly in dev. Your tests pass because you're only testing with legitimate paths.
- AI-generated code won't flag it. If you're vibe-coding with an LLM and you say "redirect the user back after login," you'll get clean, working, vulnerable code. The AI gives you what you asked for — a redirect. It doesn't add validation unless you ask.
- Frameworks don't always protect you. Some do (Rails, Django have opt-in safeguards). Many don't. If you're working with raw query params in any JS framework — Astro, Next, SvelteKit, Express — you're on your own.
The Fix
Validate that the redirect target is a relative path on your own site. That's it. Two checks:
function getSafeRedirectUrl(url) {
if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('//')) {
return url;
}
return '/account'; // safe default
}
Why both checks?
startsWith('/')ensures it's a path, nothttps://evil.com!startsWith('//')blocks protocol-relative URLs like//evil.com, which browsers resolve tohttps://evil.com
That's the minimum. If you want to be thorough:
function getSafeRedirectUrl(url) {
if (typeof url !== 'string') return '/account';
try {
const parsed = new URL(url, 'https://placeholder.com');
if (parsed.host !== 'placeholder.com') return '/account';
} catch {
// not a valid URL — fall through to path checks
}
if (!url.startsWith('/') || url.startsWith('//')) return '/account';
return url;
}
Where To Apply It
This is the part people miss. You don't just validate on the client. You validate everywhere the parameter is read:
- Client-side redirect (after login form submission)
- OAuth initiation (when you stash the return URL in session/state before redirecting to Google/GitHub/etc.)
- OAuth callback (when you pull it back out after the provider redirects back to you)
- Server-side API routes that issue 302 redirects
If there are three places in your code that read returnUrl, you need validation in all three. Not one. Not "the main one." All of them.
A Checklist for Your Codebase
Search your code right now for these patterns:
window.location.href =
window.location.replace(
res.redirect(
Response.redirect(
302
301
returnUrl
redirect_uri
next=
callback=
For every match, ask: Is the destination validated? If it came from a query param, cookie, header, or any user-controlled input — it needs validation.
For the Vibe-Coders
If you're building fast with AI tools, here's what to internalize:
- LLMs optimize for "works," not "secure." When you prompt for a login flow, you'll get a working login flow. Security constraints are things you have to specify or add yourself.
- Copy-paste from tutorials has the same problem. Most blog posts and docs show the happy path.
- Make it a habit: any time you write a redirect that uses external input, pause and validate. It's two lines of code. There is no excuse not to.
- "But it's just an internal tool" — internal tools get shared. URLs get forwarded. Bookmarks get clicked months later. Validate anyway.
It's Two Lines of Code
Open redirects are embarrassing because the fix is so small. The gap between "vulnerable" and "secure" is a single if statement. The hard part isn't writing the fix — it's knowing you need one.
Now you know. Go grep your codebase.