Open Redirect: Code Review Guide
Table of Contents
1. Introduction to Open Redirects
An open redirect (also called an unvalidated redirect) occurs when a web application takes a user-supplied URL or path and redirects the user to it without proper validation. Attackers exploit this to redirect victims from a trusted domain to a malicious site — enabling phishing, credential theft, and OAuth token hijacking.
Why Open Redirects Matter
Open redirects are frequently underestimated because "they only redirect." In reality, they are trust exploits. Victims see a legitimate domain in the URL bar and click confidently. This makes open redirects a cornerstone of phishing campaigns — and when combined with OAuth flows, they can steal access tokens directly. OWASP classifies unvalidated redirects under A10:2021 Server-Side Request Forgery and it was previously a dedicated Top 10 entry.
In this guide, you'll learn how to spot open redirect patterns during code review, why simple validation like "starts with /" can be bypassed, which framework-specific patterns are dangerous, how attackers chain open redirects with OAuth and SSO, and what prevention strategies actually work.
Open Redirect Attack Flow
How Open Redirects Are Exploited
Where Open Redirects Appear
A developer says open redirects aren't a real vulnerability because 'we're just redirecting, not executing code.' Why is this wrong?
2. Vulnerable Code Patterns
Open redirects appear whenever user input flows into a redirect target without validation. The most common patterns involve query parameters, form fields, headers, and path segments that control where the server sends the user.
Node.js/Express: Vulnerable redirect patterns
1// ❌ VULNERABLE: Direct query parameter redirect
2app.get('/login', (req, res) => {
3 // After successful authentication...
4 const redirectUrl = req.query.next || '/dashboard';
5 res.redirect(redirectUrl);
6 // Attacker: /login?next=https://evil.com
7});
8
9// ❌ VULNERABLE: Header-based redirect
10app.get('/redirect', (req, res) => {
11 const target = req.headers['x-redirect-url'];
12 res.redirect(target);
13});
14
15// ❌ VULNERABLE: Path parameter redirect
16app.get('/goto/:url', (req, res) => {
17 res.redirect(decodeURIComponent(req.params.url));
18 // Attacker: /goto/https%3A%2F%2Fevil.com
19});
20
21// ❌ VULNERABLE: POST body redirect
22app.post('/login', (req, res) => {
23 const { username, password, returnTo } = req.body;
24 if (authenticate(username, password)) {
25 res.redirect(returnTo); // User-controlled redirect
26 }
27});
28
29// ❌ VULNERABLE: Building URL with user input
30app.get('/redirect', (req, res) => {
31 const path = req.query.path;
32 res.redirect(`https://${req.query.host}/${path}`);
33 // Attacker: ?host=evil.com&path=phish
34});Python/Django/Flask: Vulnerable redirect patterns
1# ❌ VULNERABLE: Django — redirect to user-supplied URL
2from django.shortcuts import redirect
3
4def login_view(request):
5 # After authentication...
6 next_url = request.GET.get('next', '/dashboard')
7 return redirect(next_url)
8 # Attacker: /login?next=https://evil.com
9
10# ❌ VULNERABLE: Flask — unvalidated redirect
11from flask import Flask, redirect, request
12
13@app.route('/auth/callback')
14def auth_callback():
15 return_url = request.args.get('return_to', '/')
16 return redirect(return_url)
17
18# ❌ VULNERABLE: Using urllib without validation
19import urllib.parse
20
21def safe_redirect(request):
22 url = request.GET.get('url')
23 parsed = urllib.parse.urlparse(url)
24 # Developer THINKS this is safe because they "parsed" the URL
25 # but they never actually check the parsed result
26 return redirect(url)Java/Spring: Vulnerable redirect patterns
1// ❌ VULNERABLE: Spring MVC — redirect: prefix with user input
2@GetMapping("/login")
3public String login(@RequestParam(defaultValue = "/home") String next) {
4 // After authentication...
5 return "redirect:" + next;
6 // Attacker: /login?next=https://evil.com
7}
8
9// ❌ VULNERABLE: HttpServletResponse.sendRedirect
10@GetMapping("/goto")
11public void goTo(HttpServletRequest req, HttpServletResponse res)
12 throws IOException {
13 String target = req.getParameter("url");
14 res.sendRedirect(target);
15}
16
17// ❌ VULNERABLE: Spring redirect with URI building
18@GetMapping("/redirect")
19public RedirectView redirect(@RequestParam String target) {
20 return new RedirectView(target);
21}Common Redirect Parameter Names to Search For
| Parameter | Context | Example |
|---|---|---|
| next, next_url | Post-login redirect | /login?next=/dashboard |
| redirect, redirect_url | Generic redirect | /auth?redirect=... |
| redirect_uri | OAuth flows | /authorize?redirect_uri=... |
| return, returnTo, return_url | Return after action | /sso?returnTo=... |
| goto, go, url, link | Link shorteners, trackers | /goto?url=... |
| target, dest, destination | Navigation handlers | /nav?target=... |
| continue, forward, rurl | Multi-step flows | /step2?continue=... |
| success_url, cancel_url | Payment flows | /pay?success_url=... |
A developer writes: `const next = req.query.next; if (next) res.redirect(next); else res.redirect('/');` — They say it's safe because the redirect only happens if 'next' is provided. Is this correct?
3. Common Bypass Techniques
Developers often attempt to fix open redirects with naive validation — checking if the URL starts with /, contains the expected domain, or uses a blocklist. Attackers have well-known techniques to bypass each of these approaches.
Bypass techniques against naive validation
1// ❌ BYPASS 1: "starts with /" check
2// Developer's validation:
3if (url.startsWith('/')) { res.redirect(url); }
4
5// Attacker bypasses with protocol-relative URL:
6// /login?next=//evil.com
7// Browsers interpret "//evil.com" as "https://evil.com"
8
9// Also bypassed with:
10// /login?next=/\evil.com (backslash treated as slash)
11// /login?next=/%2Fevil.com (double-encoded slash)
12
13
14// ❌ BYPASS 2: Domain substring check
15// Developer's validation:
16if (url.includes('trusted.com')) { res.redirect(url); }
17
18// Attacker bypasses with:
19// ?next=https://trusted.com.evil.com (subdomain of evil.com)
20// ?next=https://evil.com/trusted.com (path contains domain)
21// ?next=https://evil.com?q=trusted.com (query contains domain)
22// ?next=https://trusted.com@evil.com (userinfo — redirects to evil.com!)
23
24
25// ❌ BYPASS 3: Regex-based domain check
26// Developer's validation:
27if (/^https:\/\/trusted\.com/.test(url)) { res.redirect(url); }
28
29// Attacker bypasses with:
30// ?next=https://trusted.com.evil.com/phish (regex matches prefix)
31
32// ❌ BYPASS 4: URL object hostname check (incomplete)
33// Developer's validation:
34const parsed = new URL(url);
35if (parsed.hostname === 'trusted.com') { res.redirect(url); }
36
37// Attacker bypasses with:
38// ?next=https://trusted.com@evil.com
39// URL parser sees hostname='evil.com' (userinfo before @)
40// WAIT — actually this one IS caught by URL parser.
41// But this is NOT caught:
42// ?next=javascript:alert(1) — if used in href, not redirect
43// ?next=https://trusted.com%00.evil.com — null byte injection (legacy)
44
45
46// ❌ BYPASS 5: Blocking "http://" and "https://"
47// Developer's validation:
48if (!url.startsWith('http://') && !url.startsWith('https://')) {
49 res.redirect(url);
50}
51
52// Attacker bypasses with:
53// ?next=//evil.com (protocol-relative)
54// ?next=HTTPS://evil.com (case variation)
55// ?next=\\evil.com (backslash)
56// ?next=data:text/html,... (data URI — in some contexts)Bypass Technique Summary
| Validation | Bypass | Why It Works |
|---|---|---|
| startsWith("/") | //evil.com | Protocol-relative URLs start with / but redirect off-domain |
| startsWith("/") | /\evil.com | Backslash is treated as forward slash by browsers |
| includes("trusted.com") | trusted.com.evil.com | Substring match catches subdomains of attacker domain |
| includes("trusted.com") | evil.com?q=trusted.com | Domain string appears in query/path, not hostname |
| includes("trusted.com") | trusted.com@evil.com | userinfo@host syntax — browser navigates to evil.com |
| !startsWith("http") | //evil.com or HTTPS://evil.com | Protocol-relative or case variation bypasses prefix check |
| endsWith("trusted.com") | nottrusted.com | Suffix check allows any prefix before the domain |
The "userinfo@host" Trick
URLs support an optional userinfo component: https://user:pass@host/path. When an attacker crafts https://trusted.com@evil.com, the browser navigates to evil.com with "trusted.com" as the username. Any validation that checks if "trusted.com" appears in the URL without proper URL parsing will be bypassed. Always parse the URL and check the hostname property specifically.
A developer validates redirect URLs with: `if (url.startsWith('/') && !url.startsWith('//'))`. Can this still be bypassed?
4. Framework-Specific Patterns
Each web framework has its own redirect APIs and built-in protections (or lack thereof). Understanding framework-specific behavior is critical for code review, as some frameworks offer partial protection that developers may over-rely on.
Django: Built-in protection and its limits
1# Django's url_has_allowed_host_and_scheme (formerly is_safe_url)
2# provides built-in open redirect protection
3from django.utils.http import url_has_allowed_host_and_scheme
4
5def login_view(request):
6 next_url = request.GET.get('next', '')
7
8 # ✅ SECURE: Uses Django's built-in validation
9 if url_has_allowed_host_and_scheme(
10 url=next_url,
11 allowed_hosts={request.get_host()},
12 require_https=request.is_secure(),
13 ):
14 return redirect(next_url)
15 return redirect('/dashboard')
16
17
18# ❌ VULNERABLE: Bypassing Django's LoginView protection
19# Django's LoginView uses url_has_allowed_host_and_scheme internally,
20# BUT only when using the default "next" parameter.
21# Custom login views often skip this:
22
23class CustomLoginView(View):
24 def post(self, request):
25 # Authenticates user...
26 return redirect(request.POST.get('redirect_to', '/'))
27 # Developer used "redirect_to" instead of "next"
28 # so Django's built-in protection doesn't apply
29
30
31# ❌ VULNERABLE: Empty allowed_hosts
32if url_has_allowed_host_and_scheme(url=next_url, allowed_hosts=set()):
33 return redirect(next_url)
34# With empty allowed_hosts, only path-relative URLs pass
35# BUT protocol-relative URLs like //evil.com are still blocked
36# This is actually SAFE — but confusing and fragileNext.js / React: Client and server redirect patterns
1// ❌ VULNERABLE: Next.js API route with unvalidated redirect
2// pages/api/login.ts
3export default function handler(req, res) {
4 const { callbackUrl } = req.query;
5 // After auth...
6 res.redirect(callbackUrl);
7}
8
9// ❌ VULNERABLE: Next.js middleware redirect
10import { NextResponse } from 'next/server';
11
12export function middleware(request) {
13 const returnUrl = request.nextUrl.searchParams.get('returnUrl');
14 // Redirecting without validation
15 return NextResponse.redirect(new URL(returnUrl, request.url));
16 // NOTE: new URL(returnUrl, request.url) resolves relative URLs
17 // against the current origin — but absolute URLs like
18 // https://evil.com override the base entirely!
19}
20
21// ✅ SECURE: Validating redirect in Next.js
22export function middleware(request) {
23 const returnUrl = request.nextUrl.searchParams.get('returnUrl');
24
25 // Parse and validate
26 try {
27 const target = new URL(returnUrl, request.url);
28 const allowedHosts = ['myapp.com', 'www.myapp.com'];
29
30 if (!allowedHosts.includes(target.hostname)) {
31 return NextResponse.redirect(new URL('/dashboard', request.url));
32 }
33 return NextResponse.redirect(target);
34 } catch {
35 return NextResponse.redirect(new URL('/dashboard', request.url));
36 }
37}
38
39// ❌ VULNERABLE: React client-side redirect
40// Attacker controls window.location via URL parameter
41function LoginCallback() {
42 const searchParams = new URLSearchParams(window.location.search);
43 const next = searchParams.get('next');
44
45 useEffect(() => {
46 window.location.href = next || '/dashboard';
47 // Client-side open redirect — same risk as server-side
48 }, [next]);
49}Spring Boot: Redirect patterns and protections
1// ❌ VULNERABLE: Spring redirect: prefix
2@GetMapping("/login")
3public String handleLogin(@RequestParam String next) {
4 return "redirect:" + next;
5}
6
7// ❌ VULNERABLE: Spring RedirectView
8@GetMapping("/redirect")
9public RedirectView redirect(@RequestParam String url) {
10 RedirectView rv = new RedirectView();
11 rv.setUrl(url);
12 return rv;
13}
14
15// ✅ SECURE: Allowlist-based validation in Spring
16@GetMapping("/login")
17public String handleLogin(@RequestParam(defaultValue = "/home") String next) {
18 if (isValidRedirect(next)) {
19 return "redirect:" + next;
20 }
21 return "redirect:/home";
22}
23
24private boolean isValidRedirect(String url) {
25 try {
26 URI uri = new URI(url);
27 // Only allow relative paths (no scheme = relative)
28 if (uri.getScheme() != null) {
29 return false;
30 }
31 // Block protocol-relative URLs
32 if (url.startsWith("//") || url.startsWith("/\\")) {
33 return false;
34 }
35 // Must start with /
36 return url.startsWith("/");
37 } catch (URISyntaxException e) {
38 return false;
39 }
40}Framework Built-in Protections
Some frameworks provide partial protection: Django's url_has_allowed_host_and_scheme() is robust when used correctly. Rails raises ActionController::Redirecting::UnsafeRedirectError for external redirects since Rails 7 (configurable via allow_other_host). Spring Security's SavedRequestAwareAuthenticationSuccessHandler validates redirect targets. Express has no built-in protection — res.redirect() will redirect to any URL. Always verify your framework's behavior rather than assuming protection exists.
A Next.js middleware uses: `new URL(returnUrl, request.url)` to resolve the redirect URL. The developer says this is safe because "it resolves relative to our origin." Is this correct?
5. Detection During Code Review
Detecting open redirects during code review requires searching for redirect/navigation APIs that consume user input. The key is tracing data flow from user-controlled sources (query params, form fields, headers) to redirect sinks.
Redirect Sinks by Language/Framework
| Language | Redirect Sink | Notes |
|---|---|---|
| Node/Express | res.redirect(), res.writeHead(302, {Location: ...}) | No built-in validation |
| Next.js | NextResponse.redirect(), redirect() (server actions) | URL constructor can bypass validation |
| Django | redirect(), HttpResponseRedirect() | Use url_has_allowed_host_and_scheme() |
| Flask | redirect(), make_response() with Location header | No built-in validation |
| Spring | "redirect:" prefix, RedirectView, sendRedirect() | Spring Security has partial protection |
| Rails | redirect_to | Rails 7+ blocks external redirects by default |
| PHP | header("Location: ..."), wp_redirect() | No built-in validation |
| Client JS | window.location, window.location.href, location.assign() | Client-side open redirect |
Grep patterns to find potential open redirects
1# Search for redirect sinks consuming variables
2rg "res\.redirect\(.*req\." --type js --type ts
3rg "redirect\(.*request\." --type py
4rg "sendRedirect\(.*req\." --type java
5rg "redirect_to.*params\[" --type ruby
6
7# Search for common redirect parameter names
8rg "(next|redirect|returnTo|goto|url|target|dest|forward|continue|callback)" \
9 --type js --type ts -g "*.tsx" | rg "(query|param|searchParam)"
10
11# Search for Location header manipulation
12rg "Location.*req\." --type js --type ts
13rg "HttpResponseRedirect|redirect\(" --type py
14rg "header\(.*Location" --type php
15
16# Search for client-side redirects
17rg "window\.location\s*=" --type js --type ts
18rg "location\.assign\(" --type js --type ts
19rg "location\.replace\(" --type js --type ts- Trace all redirect parameters back to their source — If a redirect target comes from
req.query,req.body,req.headers, orreq.params, it is user-controlled and must be validated. - Check for URL construction with user input — String concatenation like
`https://${req.query.host}/path`is just as dangerous as a direct redirect parameter. - Don't trust framework defaults — Verify whether your framework's redirect function validates URLs. Most (Express, Flask, PHP) do not.
- Look for client-side redirects too —
window.location.href = params.get("next")is an open redirect even though it happens in the browser. - Review OAuth redirect_uri handling — The
redirect_uriparameter in OAuth flows is the highest-impact open redirect vector because it can leak authorization codes and tokens. - Search for indirect redirects — Meta refresh tags (
<meta http-equiv="refresh" content="0;url=...">) and JavaScript navigation in server-rendered HTML are also redirect sinks.
During code review, you find `res.redirect(req.query.next || '/dashboard')`. The developer says the `|| '/dashboard'` fallback makes it safe. What do you flag?
6. Prevention Strategies
Preventing open redirects requires a layered approach. The most reliable strategy is to avoid using user input in redirects entirely. When that is not possible, use strict validation with proper URL parsing.
Recommended prevention: Allowlist and URL parsing
1// ✅ STRATEGY 1: Indirect reference map (most secure)
2// Map user input to predefined destinations
3const REDIRECT_MAP = {
4 'dashboard': '/dashboard',
5 'profile': '/account/profile',
6 'settings': '/account/settings',
7 'billing': '/account/billing',
8};
9
10app.get('/login', (req, res) => {
11 const key = req.query.next;
12 const target = REDIRECT_MAP[key] || '/dashboard';
13 res.redirect(target);
14 // Attacker can only redirect to predefined paths
15});
16
17
18// ✅ STRATEGY 2: Strict URL parsing with hostname allowlist
19function validateRedirectUrl(url, allowedHosts) {
20 try {
21 // Reject protocol-relative URLs before parsing
22 if (url.startsWith('//') || url.startsWith('/\\')) {
23 return null;
24 }
25
26 // Parse relative URLs against a known base
27 const parsed = new URL(url, 'https://placeholder.invalid');
28
29 // If the URL was absolute, hostname won't be placeholder
30 if (parsed.hostname !== 'placeholder.invalid') {
31 // Absolute URL — check against allowlist
32 if (!allowedHosts.includes(parsed.hostname)) {
33 return null;
34 }
35 }
36
37 // Block non-HTTP schemes (javascript:, data:, etc.)
38 if (!['https:', 'http:'].includes(parsed.protocol)) {
39 // For relative URLs, protocol comes from the placeholder
40 if (parsed.hostname !== 'placeholder.invalid') {
41 return null;
42 }
43 }
44
45 return url;
46 } catch {
47 return null;
48 }
49}
50
51app.get('/login', (req, res) => {
52 const next = req.query.next;
53 const validated = validateRedirectUrl(next, ['myapp.com', 'www.myapp.com']);
54 res.redirect(validated || '/dashboard');
55});
56
57
58// ✅ STRATEGY 3: Relative-path-only with strict validation
59function isRelativePath(url) {
60 // Must start with exactly one forward slash
61 if (!url.startsWith('/') || url.startsWith('//')) {
62 return false;
63 }
64 // Reject backslash variations
65 if (url.includes('\\')) {
66 return false;
67 }
68 // Parse to verify no scheme or authority
69 try {
70 const parsed = new URL(url, 'https://placeholder.invalid');
71 return parsed.hostname === 'placeholder.invalid';
72 } catch {
73 return false;
74 }
75}Python/Django prevention patterns
1# ✅ SECURE: Using Django's built-in protection
2from django.utils.http import url_has_allowed_host_and_scheme
3
4def safe_redirect(request):
5 next_url = request.GET.get('next', '')
6
7 if url_has_allowed_host_and_scheme(
8 url=next_url,
9 allowed_hosts={request.get_host()},
10 require_https=True,
11 ):
12 return redirect(next_url)
13 return redirect('/dashboard')
14
15
16# ✅ SECURE: Custom validation with urllib.parse
17from urllib.parse import urlparse
18
19ALLOWED_HOSTS = {'myapp.com', 'www.myapp.com'}
20
21def validate_redirect(url: str) -> str | None:
22 if not url:
23 return None
24
25 # Block protocol-relative URLs
26 if url.startswith('//') or url.startswith('/\\'):
27 return None
28
29 parsed = urlparse(url)
30
31 # If scheme is present, it's an absolute URL — validate host
32 if parsed.scheme:
33 if parsed.scheme not in ('http', 'https'):
34 return None
35 if parsed.netloc not in ALLOWED_HOSTS:
36 return None
37 return url
38
39 # No scheme — check if netloc is present (protocol-relative)
40 if parsed.netloc:
41 return None
42
43 # Relative URL — must start with /
44 if url.startswith('/'):
45 return url
46
47 return NonePrevention Strategy Comparison
| Strategy | Security Level | Flexibility | Best For |
|---|---|---|---|
| Indirect reference map | Highest — no user URL in redirect | Low — predefined destinations | Login flows with known destinations |
| Hostname allowlist + URL parsing | High — proper parsing catches bypasses | Medium — allows any path on allowed hosts | Multi-domain apps, OAuth |
| Relative-path-only | High — blocks all external redirects | Medium — only same-origin paths | Single-domain apps |
| Blocklist (http://, //, etc.) | Low — easily bypassed | High — minimal restrictions | Not recommended |
| Substring check (includes domain) | Very Low — trivially bypassed | High | Not recommended |
Defense in Depth: Content-Security-Policy
While CSP doesn't directly prevent server-side open redirects, the form-action directive can restrict where forms submit to, limiting some redirect vectors. More importantly, if an open redirect is chained with XSS, a strong CSP limits the attacker's options. Consider CSP as one layer in your defense.
Which prevention strategy is most secure for a post-login redirect where users can only land on /dashboard, /profile, or /settings?