Open redirect vulnerabilities are among the most frequently dismissed findings in security reviews. “CVSS 4.3, medium, low exploitability” — and it gets moved to the backlog. That dismissal is a mistake. Open redirects consistently appear in real-world attack chains as the step that converts a finding that looks theoretical into one that results in account takeover or credential theft. They chain. That is the point.
OWASP listed unvalidated redirects and forwards as a dedicated category (A10:2013) before merging it into A01:2021 (Broken Access Control). The name changed; the problem did not.
What Is an Open Redirect?
An open redirect occurs when a web application uses attacker-controlled input to determine a redirect destination without validating that the destination is safe. The classic pattern looks like this:
https://app.example.com/login?next=https://evil.com/steal-tokens
The application processes authentication, then does:
return redirect(request.args.get("next"))
The user, believing they are on app.example.com, gets redirected to evil.com. Their browser shows the initial app.example.com URL in the address bar until the redirect fires, lending the link credibility it does not deserve.
Why “Low Severity” Is Wrong
Open redirects are dangerous in three specific scenarios that make the CVSS base score meaningless in context:
1. OAuth State Parameter Abuse
OAuth 2.0 flows frequently use a redirect_uri parameter validated against a registered allowlist, but some providers validate only the base domain rather than the exact path. If https://app.example.com/oauth/callback is registered, some providers will also accept https://app.example.com/open-redirect?url=https://attacker.com/steal.
The OAuth flow fires, the authorization code is delivered to app.example.com/open-redirect, the open redirect fires, and the authorization code lands at attacker.com/steal in the code parameter. If the attacker can complete the PKCE exchange or if the application uses the implicit flow, the user’s access token is stolen.
This is not theoretical. Numerous OAuth implementations have been exploited this way, and the open redirect is the only precondition needed on the application side.
2. Phishing Amplification
Phishing campaigns using legitimate domains have dramatically higher click rates than campaigns from unknown domains. An open redirect on a known, trusted domain converts a app.example.com URL into a redirect to a credential-harvesting page — passing link preview checks, reputation filters, and user visual inspection.
Email security gateways that rewrite and sandbox URLs increasingly follow redirect chains, but many only follow one hop. Two-hop redirects (example.com/redirect → redirect-service.com → evil.com) are commonly used to bypass this.
3. SSRF Escalation
In environments where server-side request forgery (SSRF) exists, an open redirect on an external domain can bypass SSRF allowlists. A common mitigation for SSRF is allowlisting permitted destination domains. If the application makes server-side requests based on user input, and only allows requests to trusted-api.example.com, an attacker who discovers an open redirect on trusted-api.example.com can chain:
Application SSRF → trusted-api.example.com/redirect?url=http://169.254.169.254/latest/meta-data/
The application’s SSRF protection sees trusted-api.example.com and permits the request. The open redirect fires on that server, the new request reaches the metadata service. This pattern was observed in multiple AWS S3 presigned URL abuse chains.
Finding Open Redirects
Common Vulnerable Patterns
/redirect?url=
/login?next=
/logout?returnTo=
/auth/callback?redirect_uri=
/sso?goto=
/link?to=
/track?click_url=
Marketing tracking and analytics integrations are particularly fertile ground — click tracking frequently passes destination URLs as parameters through redirect endpoints.
Manual Testing
Test with these redirect targets, in this order:
# Absolute external URL
/redirect?url=https://burpcollaborator.net
# Protocol-relative URL (bypasses http-only checks)
/redirect?url=//burpcollaborator.net
# @-based bypass (browser treats everything before @ as credentials)
/redirect?url=https://[email protected]
# Encoded slashes (bypass string-match filters)
/redirect?url=https:%2F%2Fburpcollaborator.net
# Double-encoded
/redirect?url=https:%252F%252Fburpcollaborator.net
# Backslash (some parsers normalise to forward slash)
/redirect?url=https:\\burpcollaborator.net
# Fragment abuse
/redirect?url=https://example.com#.burpcollaborator.net
Secure Redirect Patterns
The Correct Fix: Allowlist, Not Blocklist
The fundamental mistake in most broken redirect implementations is blocklist thinking: trying to reject “bad” URLs. URL parsers are complex and the space of bypass techniques is large. The correct fix is to only permit known-good destinations.
Python (Flask)
from urllib.parse import urlparse, urljoin
from flask import request, redirect, abort
ALLOWED_HOSTS = {"app.example.com", "dashboard.example.com"}
def safe_redirect(target: str, default: str = "/"):
"""
Only redirect to URLs on the allowlisted host set.
Relative paths are permitted and resolved against the request host.
"""
if not target:
return redirect(default)
parsed = urlparse(target)
# Allow relative paths (no scheme/host)
if not parsed.scheme and not parsed.netloc:
return redirect(urljoin(request.host_url, target))
# Reject anything pointing off-domain
if parsed.netloc not in ALLOWED_HOSTS:
abort(400, "Invalid redirect target")
# Enforce HTTPS for external targets
if parsed.scheme not in ("https",):
abort(400, "Only HTTPS redirects permitted")
return redirect(target)
JavaScript (Express/Node.js)
const { URL } = require('url');
const ALLOWED_HOSTS = new Set(['app.example.com', 'dashboard.example.com']);
function safeRedirect(res, target, defaultPath = '/') {
if (!target) {
return res.redirect(defaultPath);
}
// Allow relative paths
if (!target.startsWith('http://') && !target.startsWith('https://')) {
// Sanitize relative path: strip leading protocol-relative URLs
const cleaned = target.replace(/^\/\//, '/');
return res.redirect(cleaned.startsWith('/') ? cleaned : `/${cleaned}`);
}
try {
const parsed = new URL(target);
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
return res.status(400).send('Invalid redirect target');
}
if (parsed.protocol !== 'https:') {
return res.status(400).send('Only HTTPS redirects permitted');
}
return res.redirect(target);
} catch {
return res.status(400).send('Malformed URL');
}
}
Go
package redirect
import (
"net/http"
"net/url"
"strings"
)
var allowedHosts = map[string]bool{
"app.example.com": true,
"dashboard.example.com": true,
}
func SafeRedirect(w http.ResponseWriter, r *http.Request, target, defaultPath string) {
if target == "" {
http.Redirect(w, r, defaultPath, http.StatusFound)
return
}
// Reject protocol-relative URLs before URL parsing
if strings.HasPrefix(target, "//") {
http.Error(w, "Invalid redirect target", http.StatusBadRequest)
return
}
parsed, err := url.Parse(target)
if err != nil || parsed.Scheme == "" && parsed.Host == "" {
// Relative path: sanitize and permit
if strings.HasPrefix(target, "/") {
http.Redirect(w, r, target, http.StatusFound)
return
}
http.Error(w, "Invalid redirect target", http.StatusBadRequest)
return
}
if !allowedHosts[parsed.Host] {
http.Error(w, "Invalid redirect target", http.StatusBadRequest)
return
}
if parsed.Scheme != "https" {
http.Error(w, "Only HTTPS redirects permitted", http.StatusBadRequest)
return
}
http.Redirect(w, r, target, http.StatusFound)
}
CSRF Token Binding for Sensitive Redirects
For post-authentication redirects (login → next parameter), bind the redirect target to the session at the time of login initiation rather than trusting the parameter at completion:
# At login page render: store the safe target in session
@app.route('/login', methods=['GET'])
def login_form():
next_url = request.args.get('next', '/')
# Validate before storing
session['post_login_redirect'] = validate_redirect(next_url)
return render_template('login.html')
# At login completion: use session value, not request parameter
@app.route('/login', methods=['POST'])
def login_submit():
# ... authenticate user ...
redirect_target = session.pop('post_login_redirect', '/')
return redirect(redirect_target)
This pattern eliminates the ability to substitute a malicious redirect target in the POST request completing the login.
Testing in CI: Semgrep Rules
Add these Semgrep patterns to catch open redirects in code review:
# .semgrep/open-redirect.yml
rules:
- id: flask-open-redirect
patterns:
- pattern: flask.redirect(request.$ARGS.get(...))
message: "Potential open redirect: redirect target from request arguments"
languages: [python]
severity: WARNING
- id: express-open-redirect
patterns:
- pattern: res.redirect($REQ.query.$PARAM)
message: "Potential open redirect: redirect target from query parameter"
languages: [javascript, typescript]
severity: WARNING
Summary
Open redirects need to be fixed, not deferred. The vulnerability is in itself medium-severity, but its value to an attacker is almost always in what it enables — OAuth token theft, phishing amplification, SSRF escalation. The fix is straightforward: use an allowlist of permitted redirect destinations and reject everything else. Blocklisting schemes, character-matching on domain names, or stripping parameters are all bypassable. Allowlisting is not.