CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
Overview
An open redirect occurs when an application takes a redirect destination from user-controllable input and sends the user there without validating it. The attacker crafts a link that starts on a domain the victim trusts and ends on one the attacker controls.
Example: User clicks https://trusted-site.com/login?redirect=http://evil-site.com and the application redirects to the attacker-controlled evil-site.com.
Relationship to Other CWEs
A redirect finding belongs here: CWE-601 has no children to drop to, and its parent CWE-610 (Externally Controlled Reference to a Resource in Another Sphere) has no page here. One MITRE caveat is worth settling before filing, though - whether an unvalidated redirect is a weakness at all is "subject to the intended behavior of the application", and a search engine or a link-tracking endpoint may redirect to arbitrary URLs by design. That is a question about what the endpoint promises, not a defense: if the destination was meant to be constrained and is not, this page is the right level.
The pages around it differ by who follows the URL, and by what is wrong with it:
- CWE-601 (this page) - the user's browser is sent to an attacker-chosen destination, and what the attacker gains is your domain's credibility in front of the user
- CWE-918 (Server-Side Request Forgery) - the server fetches the URL, so what the attacker gains is its network position rather than your reputation. The two meet twice: an open redirect here becomes an SSRF finding only once something server-side follows it, and an open redirect on a host your SSRF allowlist trusts is how that allowlist gets walked out of after the check passed
- CWE-99 (Improper Control of Resource Identifiers) - the Class-level entry for untrusted input choosing which resource the application acts on. A redirect target is one of its sinks, so a finding that arrives labelled CWE-99 with a
Locationheader at the end of the flow belongs here; it is a pointer by sink, not an ancestor of this page in any view - CWE-113 (HTTP Request/Response Splitting) - the same parameter, but the defect is CR and LF in the value forging extra headers rather than a well-formed URL naming the wrong host. An allowlist of acceptable destinations closes both at once; a CRLF filter closes only that one
- CWE-93 (CRLF Injection) - the same character trick against sinks that are not HTTP headers, such as a log line or an email header. It is worth knowing about here because of the failure it names: stripping CR and LF from a redirect target and closing the ticket fixes that half and leaves this page's half completely untouched
OWASP Classification
A01:2025 - Broken Access Control
Risk
Medium to High: The link starts on a domain the victim already trusts, and that is what makes it work: they see the familiar host and their guard drops. What an attacker can do with it:
- Phishing - a fake login page that harvests credentials, with the trusted domain doing the work of convincing the victim
- Malware delivery from a drive-by-download site
- OAuth token theft, intercepting an authorization code or access token mid-flow
- An XSS vector, where the redirect accepts a javascript: or data: URL
- Spam or malicious content distributed under the trusted domain
- SEO poisoning, manipulating search rankings by abusing the domain's standing
Remediation Steps
Core Principle: Never let untrusted input control a redirect target. Every destination is either server-defined or selected from an allowlist.
Locate the open redirect vulnerability
- Find the file, line and redirect call named in the finding
- Identify the parameters that control redirect destinations (
?next=,?redirect=,?returnUrl=,?continue=) - Check the usual hotspots: post-login redirects, logout confirmation pages, OAuth/SAML callback flows, and payment/checkout "continue" URLs
- Trace how untrusted data (user input, external files, databases, network requests) reaches the redirect
- Locate every call that performs a redirect (
Response.Redirect(),redirect(),header("Location:"),sendRedirect()), not only the one named in the finding - Check whether the destination is validated before use
Eliminate direct use of untrusted data in redirects (Primary Defense)
- Replace the URL parameter with an opaque identifier - a number or token, not a URL
- Keep the identifier-to-URL mapping server-side:
1 -> /dashboard,2 -> /profile - The request then carries only the identifier (
?next=2); the handler looks the destination up and redirects to what it finds, so the caller cannot name an arbitrary URL - Validation becomes an ID lookup, which is cheap to audit and hard to get wrong
- For return-after-login, store the destination rather than passing it: put the path in the session when you send the user to the login page and read it back afterwards, or issue it as a signed token and verify the signature here. Both keep the identifier opaque without needing a mapping entry per page
- Why this is the primary defense and the two below are not: it removes the weakness rather than constraining it. Nothing the request carries reaches a URL parser, so none of the parser disagreements the rest of this page is about - protocol-relative forms, backslashes, userinfo, encoded separators, control characters a browser strips - can apply. An allowlist and a validator are the vulnerable pattern with a check in front of it, and they hold only for as long as the check keeps up with the parsers. Use them where the destination genuinely has to arrive in the request, such as an arbitrary partner URL - and validate there as well as here, since a signed or session-stored value can still have been signed by an older code path
Use allowlist of permitted redirect destinations
- Define the allowlist in configuration, never from data the request can reach
- For internal redirects, list relative paths only:
/dashboard,/profile,/settings - For external redirects, list specific full URLs:
https://trusted-partner.com/callback - Check the supplied value against the allowlist before issuing the redirect
- Match exactly, not by prefix. Compare the parsed host for equality, or against a dot-anchored suffix such as
.example.com. Prefix matching on the raw URL is the OAuthredirect_uribypass, and it fails in two independent ways: an entry ofhttps://app.example.comis a prefix ofhttps://app.example.com.evil.com/cb, whose host is a domain the attacker registered, and ofhttps://app.example.com@evil.com/cb, where everything before the@is userinfo and the host isevil.com. Substring and regex matching are weaker still - Anything not on the list gets an error or the default destination, never the value that was supplied
- Canonicalize before comparison: parse with the same URL semantics the framework and browser will use, reject ambiguous encodings or separators, and compare canonical scheme and host values
- Why this works: only destinations someone placed on the list server-side can be reached, so there is nothing in the request left for an attacker to steer
Add strict URL validation if direct URLs are required (Defense in Depth)
- Parse the URL with a URL library, never a regex
- Allow only the
http://andhttps://schemes, which blocksjavascript:,data:andfile: - Do not pick the host out of the string yourself. Userinfo such as
user@host, ports, IDNs and percent-encoded characters all need a real parser before hosts can be compared - Compare hosts for exact equality. Substring matching accepts both
evil.trusted.comandtrusted.com.evil.com - Validate what the browser will resolve, not the string you received: resolve the value against a fixed base with the parser your ecosystem ships (
new URL(value, base),Uri.TryCreate,url.Parse,urlparse) and compare the parsed scheme and host. Reject control characters outright. A browser deletes tab, LF and CR before parsing and the parsers do not all follow it: Node'snew URL()and Python'surlparse()delete them the same way and Go'surl.Parse()returns an error, but .NET'sUri.TryCreatepercent-encodes them into the path and PHP'sparse_url()replaces each one with an underscore (measured on PHP 8.5:/<tab>/evil.comparses to the path/_/evil.com), so both report a local path for a value the browser sends off-site - Validate on the server. A client-side check is not a control
Use redirect confirmation for external destinations
- Show an interstitial warning page before any redirect that leaves the site
- Display the full destination URL, not a shortened or encoded form
- Require a click to continue rather than redirecting on a timer
- Add
rel="noopener noreferrer"to external links so the destination cannot reach back throughwindow.opener - Log external redirects, so abuse shows up in monitoring
- A CAPTCHA on high-risk external redirects raises the cost of automated abuse
Test and verify open redirect protection
- Test that only allowlisted destinations work
- Test bypass attempts:
//evil.com,/\evil.com,https://trusted.com@evil.com- note the direction of that last one: everything before the@is userinfo, so the host isevil.com. The reverse spelling,https://evil.com@trusted.com, resolves totrusted.comand proves nothing - Test subdomain bypass:
https://trusted.com.evil.com - Test protocol-relative URLs:
//attacker.com - Test dangerous schemes:
javascript:alert(1),data:text/html,<script>alert(1)</script> - Test control characters:
/%09/evil.com,/%0a/evil.comand/%0d/evil.com, once for each transport the endpoint accepts, because only some of them percent-decode. A query string and anapplication/x-www-form-urlencodedbody do, so your check sees real tab, LF and CR bytes, the browser then deletes them, and a validator that only inspects the first two characters accepts the value while the browser reads//evil.com. Amultipart/form-datafield and a JSON body do not: the check sees a literal%09, the browser keeps it as part of the path, and accepting it is the right answer rather than a miss. Assert on what reaches theLocationheader rather than on what you submitted - Test URL encoding bypasses:
%68%74%74%70%3a%2f%2fevil.com. Note what this proves: the framework has already decoded it tohttp://evil.comby the time the validator runs, so it tests the scheme check rather than encoding. A value that is still encoded when it reaches theLocationheader, such as/%2f%2fevil.com, stays a path - browsers do not decode%2fbefore parsing - Test double encoding:
%2568%2574%2574%2570- useful only where something decodes the value a second time between the check and the sink - Test separators, backslashes, userinfo, ports, and IDN/punycode host variants
- Verify legitimate redirects to allowed destinations still work
- Re-scan and confirm both that the finding is gone and that the change introduced no new ones
Common Pitfalls
- Validating one string, redirecting a different one: Code validates
next_url, then builds the actual target by concatenating it into a different base or query template (base_url + "?returnTo=" + next_url). The string that was checked and the string that reaches theLocationheader are not the same, so the assembly step can reformat or reintroduce data the check never saw. - Allowlisting the first hop of a redirect chain, not where it leads next: Some allowlisted targets - a URL shortener, a click-tracking endpoint, an OAuth intermediate step - are redirectors themselves. Matching the first hop against the allowlist says nothing about where that hop sends the user next, so a validated redirect to a trusted tracking domain can still end up off-site.
- Treating the request's own Host header as the trusted value to compare against: Comparing a target's host to
request.getHeader("Host")(or the framework equivalent) rather than to a fixed, server-side-configured allowlist makes the comparison circular. Behind a proxy or load balancer that forwards an unvalidated Host header, the trusted side of the comparison is something the client can set too. - Assuming validation for one sink covers every place the value is reused: A target passes scheme and host validation for the
Locationheader, then the same string is reused elsewhere in the response - an email confirmation link, a<meta refresh>fallback, a value handed to client-side JavaScript - without being checked again. Each sink parses by its own rules, so validation tied to one of them says nothing about the others.
Language-Specific Guidance
- C# - ASP.NET Core, MVC - Url.IsLocalUrl() and allowlist validation
- Go - Gin, Echo, net/http - url.Parse validation and allowlisting
- Java - Servlets, Spring MVC, Jakarta EE - URI validation and redirect protection
- JavaScript/Node.js - Express, Koa - URL parser validation and same-origin checks
- PHP - Laravel, Symfony - parse_url validation and header redirects
- Python - Flask, Django, FastAPI - urlparse validation and allowlisting