Don't redirect to a URL taken from the request until you have checked it. For return-to links, accept only a path that starts with one slash, contains no backslashes or control characters, and still resolves to your own origin after parsing; otherwise use a default page. For off-site destinations, map an ID to a fixed URL or compare the parsed origin with an exact allowlist. Never validate the raw string with prefix, substring or regex checks.
- Prefer an ID, or a return path stored on the server, over a URL in the query string.
- Parse with the WHATWG URL rules browsers use, then compare origins exactly.
- Reject
//,/\, any backslash, tabs and newlines in a value meant to be a local path. - Never check the raw string with
startsWith,endsWith,includesor a regular expression. - Register OAuth redirect URIs as exact strings and remove open redirectors from the client's site.
- Don't let server-side fetchers follow redirects without checking every hop.
- List every redirect sink:
res.redirect,sendRedirect,HttpResponseRedirectorredirect(),Response.Redirect, rawLocationheaders,location.hreforlocation.assign,window.openand meta refresh. Mark each one whose target comes from a request, a cookie or stored user data. - Send the bypass payloads from this guide to each sink and resolve the
Locationheader against the request URL; every result must be your own origin or your fallback page. - Unit-test the validator with the same payloads and with legitimate paths that carry query strings and fragments, so the fix doesn't break real return links.
- Review OAuth and OIDC client registrations: redirect and post-logout redirect URIs must be exact strings, and the authorization server must show an error, not redirect, when they don't match.
- Run CodeQL (
js/server-side-unvalidated-url-redirection,py/url-redirection,java/unvalidated-url-redirection) or ZAP's External Redirect rule (20019) in CI to catch new sinks.
- Applies to
Endpoints that redirect to a URL taken from a request, including login and OAuth flows. URL parsers disagree on edge cases, so validate with the parser that performs the redirect.
- Part of
- Workflow: Fix a finding
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Explain how an open redirect enables phishing, OAuth token theft and SSRF.
- Recognise parser differences that defeat string-based checks.
- Implement and test a same-origin path check and an exact-origin allowlist.
Before you start
Developers and reviewers who build login, logout, SSO and link-handling flows
- Read URLs and HTTP redirect responses.
- Know the basic OAuth or OIDC login flow.
An open redirect is an endpoint that sends the browser to a URL taken from the request, usually a next, returnUrl or redirect parameter, without checking where it points. The classic case is the page that finishes login and returns users to where they started. The link the victim sees starts with your domain, so mail filters, link previews and people trust it, but the browser ends up on the attacker's site. CWE-601 names this weakness, and the OWASP Top 10:2025 lists it under A01 Broken Access Control.
The redirect itself is not the bug; trusting the target is. Code that redirects to a hard-coded path, or to a value it looked up on the server, cannot be steered. Code that copies a request value into a Location header, a meta refresh or location.href can.
// Vulnerable: the browser goes wherever ?next= points
app.get('/login/done', (req, res) => {
res.redirect(303, req.query.next);
});A typical attack mails a link such as https://app.example.com/login?next=https://app-example.com.evil.example/login. The victim signs in to the real site, then lands on a copy that says the password was wrong, and many people type it again. The same trick works through logout pages, email-confirmation links, language switchers and click trackers, and it weakens link scanners because the first hop is a trusted domain.
On its own an open redirect is a phishing aid. Its severity rises with what it can be chained to: an OAuth flow, a server-side fetcher that follows redirects (/domains/appsec/secure-features/ssrf-egress-boundaries/), or a client-side redirect that accepts javascript: URLs turn it into token theft, SSRF or XSS.
Open redirects hide wherever an application hands the browser a destination it received earlier, and stored values count: a return URL saved in a cookie, a profile field or a database row is as attacker-controlled as a query parameter if a user could ever set it. Search server code, templates and front-end code for these patterns:
- Return-to parameters on login, logout, registration, email-confirmation and password-reset pages.
- SSO plumbing: SAML
RelayState, OIDCpost_logout_redirect_uri, and OAuthredirect_uriand error redirects. - Outbound-link and click-tracking endpoints such as
/out?url=or/away?to=. - Language, currency and theme switchers that send the user back to the page they came from.
- Client-side code that sets
location.href, callslocation.assign,location.replaceorwindow.open, or writes a meta refresh. - Framework helpers that redirect back to the
Refererheader or to a URL named in a parameter.
Authorization servers send codes and tokens to the client's registered redirect URI. If the server matches redirect URIs against patterns, an attacker can pick a matching URI that passes through an open redirect on the client's site. RFC 9700, the OAuth 2.0 Security Best Current Practice, describes the fragment variant shown below: when a redirect's Location carries no fragment, the browser keeps the original one, so an access token in #access_token follows the second redirect to the attacker.
Authorization servers must compare redirect URIs by exact string match, with one exception: the port of a localhost redirect URI for native apps. RFC 9700 also says clients and authorization servers MUST NOT expose open redirectors, and RFC 6749 forbids an authorization server from redirecting when the client ID or redirect URI is invalid. Use the authorization code flow rather than the implicit flow, and register post-logout redirect URIs exactly too. Appending a fragment such as #_ to a Location header stops the browser carrying the old fragment over.
Authorization servers can be open redirectors as well. RFC 9700 describes an attacker who registers a client whose redirect URI is a phishing page, then sends victims an authorization request built to fail, for example with an invalid scope or prompt=none. The server's error redirect delivers them to the phishing page from a trusted domain. The server should authenticate the user first and redirect automatically only to redirect URIs it trusts.
GET /authorize?response_type=token&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2Fclient.example%2Fcb%3Fnext%3Dhttps%253A%252F%252Fevil.example%252F HTTP/1.1
Host: as.example
HTTP/1.1 303 See Other
Location: https://client.example/cb?next=https%3A%2F%2Fevil.example%2F#access_token=2YotnFZFEjr1zCsicMWpAA
GET /cb?next=https%3A%2F%2Fevil.example%2F HTTP/1.1
Host: client.example
HTTP/1.1 303 See Other
Location: https://evil.example/
Browser address bar: https://evil.example/#access_token=2YotnFZFEjr1zCsicMWpAAServer-side fetchers can be steered the same way. If an SSRF defense allows URLs on your own domain and the HTTP client follows redirects, an attacker submits https://app.example.com/login/done?next=http://169.254.169.254/latest/meta-data/. The fetcher validates the first hop, then follows your redirect to the cloud metadata service. Turn off redirect following in fetchers, or apply the full destination policy to every hop; ASVS 5.0 requirement 15.3.2 and OWASP API7:2023 both say so.
Client-side redirects add script execution. Assigning a query value to location.href or location.assign runs a javascript: URL in your origin, which is DOM-based XSS. Browsers ignore javascript: in a Location header, but not in script. Validate in the browser with the same rule, using the URL class the browser already provides.
// Vulnerable: ?next=javascript:... runs in your origin; ?next=//evil.example leaves it
const next = new URLSearchParams(location.search).get('next');
location.href = next;Browsers parse URLs by the WHATWG URL Standard. It strips leading spaces and control characters, deletes tabs and newlines anywhere in the input, treats a backslash as a slash in http and https URLs, and reads anything before an @ in the authority as credentials. Python's urllib.parse and Java's java.net.URI follow older rules, so a check built on them can approve a value that a browser reads differently. Every row below passes a common check and still leaves the site in a current browser, except where the last column says otherwise.
This is why the fixes below combine strict character rules with a parse, and then redirect to exactly the value they approved.
| Value of next | Check it passes | Where the browser goes |
|---|---|---|
//evil.example/ | startsWith('/') | https://evil.example/ (protocol-relative URL) |
/\evil.example/ | startsWith('/') and not startsWith('//') | https://evil.example/ (backslash read as a slash) |
/\t/evil.example/ with a literal tab, sent unencoded | The same prefix checks | https://evil.example/ (the tab is deleted) |
///evil.example/ | Python urlsplit and Java URI both report no host | https://evil.example/ |
https://app.example.com@evil.example/ | includes('app.example.com') or startsWith('https://app.example.com') | https://evil.example/ (the text before @ is a user name) |
https://app.example.com.evil.example/ | startsWith('https://app.example.com') | A host the attacker registered |
https://evil.example\@app.example.com/ | urlsplit(next).hostname == 'app.example.com' in Python | https://evil.example/ |
/%2Fevil.example/ checked, then decoded again | Any check made before a second decode | https://evil.example/ once it becomes //evil.example/ |
/.//evil.example/ | Every check here; it is safe as sent | Stays on your site, unless you re-emit new URL(next).pathname, which is //evil.example/ |
javascript:alert(1) | Checks that only look for a foreign host | Runs script when assigned to location.href |
A return-to link always points at your own site, so accept a path and nothing else. The function rejects anything that does not start with exactly one slash, any backslash and any control character. It then parses the value against your canonical origin with the WHATWG URL parser and confirms the origin did not change. It returns the parsed absolute URL, never a re-assembled pathname, which keeps /.//evil.example/ harmless. The typeof check also rejects the array that repeated ?next= parameters produce. Take the origin from configuration, not from the Host header, which the client controls.
const APP_ORIGIN = 'https://app.example.com';
// Accept only a same-origin path; return an absolute URL on our origin.
function localRedirectTarget(next, fallback = '/') {
const home = new URL(fallback, APP_ORIGIN).href;
if (typeof next !== 'string' || !next.startsWith('/') || next.startsWith('//')
|| next.includes('\\') || /[\x00-\x1f\x7f]/.test(next)) {
return home;
}
const url = new URL(next, APP_ORIGIN); // the WHATWG parser browsers use
return url.origin === APP_ORIGIN ? url.href : home;
}
app.get('/login/done', (req, res) => {
res.redirect(303, localRedirectTarget(req.query.next));
});urllib.parse does not treat backslashes as slashes, so the character checks carry the weight here; urlsplit then confirms there is no scheme or host. The function returns the exact string it checked. In Django, LoginView and LogoutView already validate next against the request host and success_url_allowed_hosts. The helper they use, url_has_allowed_host_and_scheme, is internal API, so write an explicit check like this one for your own views.
from urllib.parse import urlsplit
from flask import Flask, redirect, request
app = Flask(__name__)
def local_redirect_target(next_url: str | None, fallback: str = "/") -> str:
"""Return next_url only if it is a path on this site; otherwise the fallback."""
if (not next_url or not next_url.startswith("/") or next_url.startswith("//")
or "\\" in next_url
or any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in next_url)):
return fallback
parts = urlsplit(next_url)
if parts.scheme or parts.netloc:
return fallback
return next_url # redirect to the exact string you checked
@app.get("/login/done")
def login_done():
return redirect(local_redirect_target(request.args.get("next")), code=303)java.net.URI throws on backslashes, spaces and control characters, and the catch block turns that into the fallback. It also reports no authority for ///evil.example/, so the explicit // prefix check is essential. Building the response with ResponseEntity and an explicit URI keeps the Location header identical to the value the method approved.
import java.net.URI;
import java.net.URISyntaxException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class LoginRedirectController {
/** Returns next only if it is a path on this site; otherwise "/". */
static String localRedirectTarget(String next) {
if (next == null || !next.startsWith("/") || next.startsWith("//")
|| next.indexOf('\\') >= 0 || next.chars().anyMatch(c -> c < 0x20 || c == 0x7f)) {
return "/";
}
try {
URI uri = new URI(next); // rejects spaces and other illegal characters
if (uri.getScheme() != null || uri.getRawAuthority() != null) {
return "/";
}
} catch (URISyntaxException e) {
return "/";
}
return next;
}
@GetMapping("/login/done")
ResponseEntity<Void> loginDone(@RequestParam(required = false) String next) {
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(URI.create(localRedirectTarget(next)))
.build();
}
}When users must leave your site, for example to a partner portal or a documentation host, avoid free-form URLs. The strongest design takes an ID such as ?dest=docs and looks the URL up on the server. For return-to flows, store the original path in the session, or in the OAuth state, before sending the user to log in, and use only the stored copy afterwards. SAML RelayState should be an opaque lookup key for the same reason.
If you must accept a URL, parse it, reject credentials and compare the whole origin (scheme, host and port) against an exact set. Suffix rules such as endsWith('.example.com') trust every subdomain, including forgotten ones an attacker could take over. ASVS 5.0 requires an allowlist for automatic off-site redirects (3.7.2) and, at level 3, a notice page that shows the destination and lets the user cancel (3.7.3).
Links that must carry a destination through the browser, such as click tracking in email, can sign it instead: the server computes an HMAC over the destination with a key only it holds, and the redirect endpoint follows only URLs whose signature verifies. On a notice page, render the destination as text and make the user click; never echo it into markup or a meta refresh.
// For the few off-site destinations you deliberately support.
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://docs.example.com']);
function allowedExternalTarget(target) {
let url;
try { url = new URL(target); } catch { return null; } // relative or malformed
if (url.username || url.password) return null;
return ALLOWED_ORIGINS.has(url.origin) ? url.href : null;
}
// Better still: accept an ID and look the URL up on the server.
const DESTINATIONS = { docs: 'https://docs.example.com/guide', status: 'https://status.example.com/' };ASP.NET Core's LocalRedirect throws when the target is not local, and Url.IsLocalUrl and RedirectHttpResult.IsLocalUrl reject every unsafe value in the table above, as sent, while accepting ordinary paths. Django's LoginView and LogoutView validate next for you. Express, Flask and Spring MVC send whatever you pass to their redirect helpers, so the check is yours. Fix cards for each stack are at /fix/open-redirect/. Whatever helper you rely on, confirm it rejects backslashes, because that is where hand-written and older checks most often fail.
Most real open redirects have a check; it just tests the wrong thing.
- Validating one value and redirecting another: checking
nextbut sendingreturnTo, or checking before a second decode. - Building absolute URLs from the
HostorX-Forwarded-Hostheader, which the client controls unless a trusted proxy overwrites it. - Redirecting back to the
Refererheader after an action; it is attacker-influenced input too. - Allowing every subdomain; one hosted on a third-party service or left dangling in DNS is enough.
- Chains: a local path that is itself a redirect endpoint, such as
/out?url=..., turns a same-origin check back into an open redirect. - Meta refresh tags,
Refreshheaders andwindow.opencalls built from input; they are redirect sinks too. - Rejecting silently: log each rejected target with its parsed host, not the full URL, which can carry tokens, so probing shows up in monitoring.
CVE-2019-3778 was an open redirect in Spring Security OAuth: the DefaultRedirectResolver could be tricked with a crafted redirect_uri into sending the OAuth authorization code to an attacker-controlled URL. The missing control was exact-match validation of the redirect URI against the registered value.
Find every place that sets a redirect, call each one with the payloads from the table, and resolve the Location header the way a browser would. The script does that for a GET endpoint and exits non-zero on any failure, so it can run in CI against staging. Reuse the same list in unit tests for your validator, together with legitimate paths that carry query strings and fragments.
Static analysis finds new sinks as code changes: CodeQL ships js/server-side-unvalidated-url-redirection, py/url-redirection and java/unvalidated-url-redirection, and ZAP's active scan includes an External Redirect rule (20019).
// Usage: node check-redirects.mjs https://app.example.com/login/done
// Sends bypass payloads in ?next= and resolves each Location the way a browser would.
const endpoint = new URL(process.argv[2]);
const payloads = [
'//evil.example/', '///evil.example/', '/\\evil.example/', '/\t/evil.example/',
'https://app.example.com@evil.example/', 'https://evil.example\\@app.example.com/',
'https:\\\\evil.example', '/.//evil.example/', 'javascript:alert(1)',
];
let failures = 0;
for (const next of payloads) {
const url = new URL(endpoint);
url.searchParams.set('next', next);
const res = await fetch(url, { redirect: 'manual' });
const location = res.headers.get('location');
const ok = !location || new URL(location, url).origin === endpoint.origin;
if (!ok) failures++;
console.log(ok ? 'ok ' : 'FAIL', JSON.stringify(next), '->', location);
}
process.exitCode = failures ? 1 : 0;Guided practice
Review the redirect handling of a small service before release. Decide which inputs leave the site, then specify the fixes.
Supplied scenario material
App origin: https://app.example.test
S1 GET /login/done?next=V if V starts with "/": redirect(V), else redirect("/")
S2 GET /out?url=V if "example.test" is a substring of V: redirect(V)
S3 GET /lang?return=V if V starts with "/": redirect(decodeURIComponent(V)), else redirect("/")
OAuth: the authorization server accepts any redirect_uri matching https://app.example.test/*
Link previews: a backend fetcher accepts https URLs whose host ends with app.example.test and follows redirects.
Values of V, after the framework's normal query decoding:
P1 //evil.test/ P2 /\evil.test/ P3 https://example.test.evil.test/
P4 https://evil.test/?q=example.test P5 /%2Fevil.test/ P6 /dashboard?tab=21. Classify S1
Run P1, P2 and P6 through S1. State the
Locationheader and where a current browser ends up.Expected result and reasoning
P1 produces
Location: //evil.test/and the browser leaves forevil.test. P2 passes the prefix check, and the browser reads the backslash as a slash, so it also leaves. P6 stays onapp.example.test. Replace the rule with the same-origin path check: exactly one leading slash, no backslash or control characters, then parse against the app origin and compare origins.2. Classify S2
Run P3 and P4 through S2.
Expected result and reasoning
Both pass the substring test. P3's host is
example.test.evil.testand P4's host isevil.test, so both leave the site. Replace the free-form URL with an ID mapped to fixed destinations, or parse it and compare the origin against an exact set.3. Spot the decode bug
Run P5 through S3.
Expected result and reasoning
/%2Fevil.test/passes S1's prefix check, then decodes to//evil.test/, an off-site redirect. Decode once, before validating, and redirect to exactly the value you validated.4. Follow the token
Explain what the OAuth pattern allows when S1 is left as written, for a client using
response_type=token.Expected result and reasoning
An attacker can send
redirect_uri=https://app.example.test/login/done?next=//evil.test/, which matches the pattern. The token arrives in the URL fragment, S1 redirects to//evil.test/without a fragment, and the browser carries#access_tokentoevil.test. Register the exact callback URI, use the code flow and fix S1.5. Close the SSRF chain
The preview service receives
https://app.example.test/out?url=http://169.254.169.254/latest/meta-data/?example.test. Predict the outcome.Expected result and reasoning
The host check passes, S2's substring test passes because example.test appears in the query, and the fetcher follows the redirect to the cloud metadata address. Fixing S2 removes this route, but the fetcher must also stop following redirects or re-check every hop against its destination policy.
Paper exercise: the fixture is complete. To reproduce the outcomes, run the payloads against a local test server in a current browser, never against systems you are not authorized to test.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- MITRE CWE-601: URL Redirection to Untrusted Site ('Open Redirect'). CWE 4.20.
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- RFC 9700: OAuth 2.0 Security BCP, 4.1 Insufficient Redirect URI Validation. January 2025.
- RFC 9700: OAuth 2.0 Security BCP, 4.11 Open Redirection. January 2025.
- WHATWG URL Standard: basic URL parser
- OWASP ASVS 5.0.0, V3.7 Other Browser Security Considerations. Version 5.0.0.
- Microsoft Learn: Prevent open redirect attacks in ASP.NET Core. ASP.NET Core 10.0.
- Django 6.1: LoginView.success_url_allowed_hosts
- CodeQL query help: URL redirection from remote source (py/url-redirection)
- OWASP API Security Top 10 2023: API7 Server Side Request Forgery
- NVD: CVE-2019-3778, open redirect in Spring Security OAuth
Reference information
- Tags
- Server-side request forgery (SSRF)Input validation and parsingAccount takeoverOAuth and OpenID Connect
- SDLC phase
- DesignDevelopment
- Role
- Developers
- OWASP WSTG v4.2 tests
- WSTG-CLNT-04