Open Redirect Vulnerabilities: Detection and Prevention
Learn how attackers exploit open redirects for phishing, the flaws in blacklist-based defenses, and safe redirect patterns for Next.js and Express.
Open Redirect Vulnerabilities: Detection and Prevention
Open redirect vulnerabilities are deceptively simple — an application takes a URL from user input and redirects the browser there without verifying that the destination is safe. The result is that a link carrying your trusted domain name delivers victims to an attacker-controlled page. Phishing campaigns, credential harvesting, and OAuth token theft all exploit this class of bug routinely.
How Open Redirects Are Abused
The canonical attack scenario looks like this:
https://app.example.com/login?next=https://evil.example.com/fake-login
A user who clicks that link lands on your legitimate login page, authenticates, and is then forwarded to a convincing replica that captures their session cookie or password. The victim sees app.example.com in the initial URL — enough to override suspicion.
Beyond phishing, open redirects are leveraged as a second step in more complex chains:
- OAuth redirect_uri abuse — Some providers apply only loose validation on the
redirect_uriparameter. An open redirect on the legitimate client origin can be combined with a crafted authorization request to leak OAuth authorization codes. - SSRF amplification — In server-side redirect handling, an open redirect may cause the server itself to fetch an internal resource.
- Reputation laundering — Spammers use redirects through reputable domains to pass email and ad-network spam filters.
Why Blacklists Fail
The instinctive fix is a blacklist: reject any next parameter that contains evil.com. Every blacklist implementation in the wild has been bypassed. The URL specification is rich enough to guarantee this.
Common bypasses:
# Protocol-relative URLs — many blacklists only check for http:// and https://
//evil.example.com/path
# Backslash normalization — browsers parse \\ as /
https://evil.example.com\@app.example.com/
# URL encoding
https://evil.example.com%2F@app.example.com/
# Double encoding
https://evil.example.com%252F@app.example.com/
# Null byte injection (rare, older parsers)
https://app.example.com%00.evil.example.com/
# Unicode normalization
https://аpp.example.com/ (Cyrillic 'а', not Latin 'a')
Any solution that tries to match strings against a deny-list will eventually be bypassed by a new encoding trick. Defense must operate at the semantic level, not the lexical level.
The Correct Approach: Allowlists and Relative Paths
Option 1 — Relative paths only
The strongest defense is to reject any next value that is not a path relative to the current origin. A relative path cannot redirect to an external domain.
function isSafeRedirect(url: string): boolean {
// Must start with / but not // (protocol-relative)
if (!url.startsWith('/') || url.startsWith('//')) {
return false;
}
// Parse to catch encoded tricks
try {
const parsed = new URL(url, 'https://app.example.com');
return parsed.hostname === 'app.example.com';
} catch {
return false;
}
}
Always parse the URL before testing it. String prefix checks on raw input miss encoded variants.
Option 2 — Explicit allowlist of safe destinations
When you genuinely need to redirect to a set of known external partners:
const ALLOWED_REDIRECT_ORIGINS = new Set([
'https://app.example.com',
'https://billing.example.com',
'https://docs.example.com',
]);
function isSafeRedirect(url: string): boolean {
try {
const parsed = new URL(url);
return ALLOWED_REDIRECT_ORIGINS.has(parsed.origin);
} catch {
return false;
}
}
The key is comparing the parsed origin (scheme + host + port), not doing a substring match on the raw string.
Safe Redirect Patterns in Next.js
Next.js server actions and route handlers both encounter this pattern. Here is a safe implementation using the App Router:
// app/actions/auth.ts
'use server';
import { redirect } from 'next/navigation';
function isSafeNext(next: string | null): string {
if (!next) return '/dashboard';
if (!next.startsWith('/') || next.startsWith('//')) return '/dashboard';
try {
// Resolve against origin to catch encoded attacks
const base = 'https://app.example.com';
const resolved = new URL(next, base);
if (resolved.origin !== base) return '/dashboard';
return resolved.pathname + resolved.search + resolved.hash;
} catch {
return '/dashboard';
}
}
export async function signIn(formData: FormData) {
const nextParam = formData.get('next');
const next = typeof nextParam === 'string' ? nextParam : null;
// ... authenticate user ...
redirect(isSafeNext(next));
}
Note that redirect() from next/navigation always produces an absolute redirect to the same origin when given a path, so this is already safe as long as your path extraction is correct. The danger is in rolling your own Response with a Location header.
Safe Redirect Patterns in Express
import express, { Request, Response } from 'express';
import { URL } from 'url';
const APP_ORIGIN = 'https://app.example.com';
function safeRedirect(res: Response, destination: string): void {
let target = '/';
try {
const parsed = new URL(destination, APP_ORIGIN);
if (parsed.origin === APP_ORIGIN) {
target = parsed.pathname + parsed.search + parsed.hash;
}
} catch {
// leave target as '/'
}
res.redirect(302, target);
}
app.get('/login', (req: Request, res: Response) => {
const next = typeof req.query.next === 'string' ? req.query.next : '/';
// ... authenticate ...
safeRedirect(res, next);
});
Detecting Open Redirects
Automated scanning
Most DAST tools (OWASP ZAP, Burp Suite Scanner, Nuclei) include open redirect checks. The Nuclei template library has a dedicated category. Run these as part of your CI/CD pipeline against a staging environment.
A quick manual check: search your codebase for any code that reads from request parameters and passes values directly to redirect functions.
# Search for common redirect sinks taking user input
grep -rn "res.redirect\|router.push\|location.href\|window.location" src/ \
| grep -i "req.query\|req.params\|searchParams\|getParam"
Code review checklist
When reviewing a PR that touches authentication flows, password reset, or post-login redirect logic, ask:
- Does the code use
new URL()to parse the destination before comparing it? - Is the comparison against
origin(not a substring of the full URL)? - Is there a safe default if validation fails?
- Are there integration tests that submit
//evil.comandhttps://evil.comand verify the redirect goes to the fallback?
Testing Your Fix
Write explicit tests for bypass patterns:
describe('isSafeRedirect', () => {
const safe = ['/dashboard', '/settings?tab=profile', '/'];
const unsafe = [
'https://evil.com',
'//evil.com',
'/\\evil.com',
'https://evil.com%2F@app.example.com/',
'javascript:alert(1)',
'',
];
safe.forEach(url => {
it(`allows ${url}`, () => expect(isSafeRedirect(url)).toBe(true));
});
unsafe.forEach(url => {
it(`blocks ${url}`, () => expect(isSafeRedirect(url)).toBe(false));
});
});
Open redirects are rated medium severity in isolation but high severity when chained with OAuth flows or used in targeted phishing against your user base. The fix is small — a single well-written validation function — and the test surface is well-defined. There is no reason to ship this vulnerability.