Choose your stack
Fix open redirects in Node.js and Express
Resolve next against your own origin with new URL(next, site), the WHATWG parser browsers use, and redirect only if url.origin is in an exact allowlist and the URL carries no user name or password; otherwise redirect to a fixed page.
Open the fix card Python · FlaskFix open redirects in Python and Flask
Refuse backslashes, which browsers read as slashes and urllib.parse does not, and control characters; then split next with urlsplit: accept a path that starts with exactly one slash, or an https URL whose hostname is in an exact allowlist, with no port and no @.
Open the fix card Java · Spring BootFix open redirects in Java and Spring Boot
Refuse backslashes and control characters, then parse next with java.net.URI: return "redirect:" + next only for a path that starts with exactly one slash, or an https URL whose host is in an exact allowlist with no port or user info; otherwise redirect to a fixed page.
Open the fix card C# · ASP.NET CoreFix open redirects in C# and ASP.NET Core
Check Url.IsLocalUrl(returnUrl) and redirect with LocalRedirect(returnUrl): IsLocalUrl accepts "/" or "~/" paths not followed by "/" or "\" and free of control characters.
Open the fix card TypeScript · Next.jsFix open redirects in TypeScript and Next.js
Resolve next against your own origin with new URL(next, site), the WHATWG parser browsers use, and pass the result to redirect() only if url.origin is in an exact allowlist and the URL has no user name or password; otherwise redirect to a fixed page.
Open the fix card Python · FastAPIFix open redirects in Python and FastAPI
Refuse backslashes and control characters, then split next with urlsplit: accept a path that starts with exactly one slash, or an https URL whose hostname is in an exact allowlist, with no port and no @.
Open the fix card Python · DjangoFix open redirects in Python and Django
Prefer Django's LoginView, which checks next itself; add other hosts with success_url_allowed_hosts.
Open the fix card Go · net/httpFix open redirects in Go and net/http
Refuse a next value containing a backslash, a control character or a space, then url.Parse it: accept a path starting with exactly one slash, or a URL whose scheme and host exactly match an allowlisted origin, without user info; otherwise a fixed page.
Open the fix card Ruby · RailsFix open redirects in Ruby on Rails
Keep Rails' own check on: redirect_to url_from(params[:next]) || account_path, with other trusted hosts in config.action_controller.allowed_redirect_hosts. url_from returns the value only for a path on this site or an allowed host, and redirect_to raises for anything else.
Open the fix card PHP · LaravelFix open redirects in PHP and Laravel
Treat next as untrusted: resolve it against the current URL with PHP 8.5's Uri\WhatWg\Url::parse(), the parser browsers use, and redirect()->away() to the serialized result only when its scheme is https and its host is on your allowlist.
Open the fix cardHow it happens
- The value starts out legitimate: a protected page links to /login?next=/reports/q3 and the sign-in handler redirects there. Nothing stops anyone from writing /login?next=https://evil.example/ in an email. The link shows your domain, the user signs in for real, and the browser lands on the attacker's page.
- Browsers resolve a Location header with the WHATWG URL Standard. They strip leading and trailing control characters and spaces, remove tabs and newlines anywhere, read \ as / in http and https URLs, skip any number of slashes before a host, and treat everything before the last @ in the authority as credentials. So //evil.example/, ///evil.example/, /\evil.example/, \\evil.example/ and https://app.example.test@evil.example/ all leave the site.
- Server-side parsers disagree with the browser on exactly those inputs. java.net.URI and Python's urlsplit report no host for ///evil.example/; urlsplit reads https://evil.example\@docs.example.test/ as host docs.example.test while the browser goes to evil.example; Spring's UriComponentsBuilder with the WhatWG parser type parses relative input against an empty base and reports no host for /\evil.example/; .NET's Uri.TryCreate("//evil.example/", UriKind.Absolute, ...) succeeds as a file: URI (a UNC path). A check that asks such a parser whether there is a host approves the attack.
- String checks fail in predictable ways. startsWith("/") accepts //evil.example/ and /\evil.example/; a host that contains or ends with your domain accepts docs.example.test.evil.example and look-alike registrations; checking one value and redirecting another, such as a decoded copy or a path rebuilt from parts, reopens the hole.
- Frameworks hand the value to the browser almost unchanged. Express percent-encodes only characters that cannot appear in a URL, and Spring MVC and ASP.NET Core send it as given. Werkzeug (Flask) rewrites Location through urllib.parse, which happened to neutralise the backslash and triple-slash values in these tests, yet //evil.example/, absolute URLs and javascript: URLs still went out. Starlette's RedirectResponse (FastAPI) percent-encodes backslashes but sends ///evil.example/ unchanged. Browsers refuse to follow a Location whose scheme is not http or https, but the same unchecked value in a link or in location.href runs as script.
Fix principles
- Avoid taking a URL at all where you can: keep the return path in the session before sending the user to sign in, or map an ID to a destination on the server.
- Accept a path on your own site, or an absolute URL whose scheme, host and port exactly match an allowlist. Compare whole values; never use startsWith, contains or endsWith on the raw string or the host.
- Decide with a parser that reads the value the way the browser will: Node's URL class implements the WHATWG URL Standard, and ASP.NET Core's IsLocalUrl encodes the browser's rules for local paths. With java.net.URI or urllib.parse, first refuse backslashes and control characters, and require a path to start with exactly one slash.
- Refuse user names and passwords in the URL and anything you cannot parse, and fall back to a fixed default page instead of an error that echoes the value.
- Redirect to exactly the value you validated or to the parser's serialisation of it, never to a decoded copy or a path rebuilt from parts: new URL('/.//evil.example/', site).pathname is //evil.example/.
- Take your own origin from configuration, not from the Host or X-Forwarded-Host header, and apply the same check to every redirect sink: sign-out, language switchers, OAuth state, Referer-based 'back' links, links rendered into pages and client-side location.href.
The scenario
Each card has a sign-in form at /login that carries next (returnUrl in ASP.NET Core) in a hidden field. A page that needs a signed-in user, /account, sends visitors to /login?next=/account. After a correct password the app signs the user in and redirects to next, or to /account when there is none. The site is https://app.example.test, and the help centre at https://docs.example.test is the one other destination allowed. The tests take the user's path: they load /login?next=<value>, submit the form with the seeded user's password, and resolve the Location header against https://app.example.test/login the way a browser does.
How the cards prove it
- Run each card's tests in three modes: the fixed implementation must pass, and the vulnerable and the plausible-but-wrong implementations must fail. run-all.mjs does this for every card.
- The ten exploit tests sign in through the real form with next set to https://evil.example/, //evil.example/, ///evil.example/, /\evil.example/, \\evil.example/, https://app.example.test@evil.example/, https://docs.example.test.evil.example/, https://evil.example\@docs.example.test/, /%2F%2Fevil.example/ and javascript:alert(document.domain), and require that the Location header, resolved the way a browser resolves it, lands on https://app.example.test or https://docs.example.test. Node uses its WHATWG URL class and Python uses ada-url, Python bindings for the same parser. The Java and C# tests use a small helper that implements the relevant steps of the standard; on a corpus of 340 Location values it returned the same origin as Node's URL class for all 337 that Node could parse, and an origin outside the allowlist for the other 3. Six functional tests keep a protected page's round trip, a path with a query and a fragment, percent-encoded characters, the docs link, the default page and a refused wrong password working.
- The encoded-slashes test passes in every mode: /%2F%2Fevil.example/ stays on the site unless code decodes the value a second time after checking it, so it guards against that regression rather than catching the vulnerable code.
- Static analysis is weak here. Each Semgrep open-redirect rule matches one shape, and on this card's code only the Spring rule fired on a vulnerable implementation (the Go rule fired only on the Go app's own, safe redirect to the sign-in page). CodeQL's default Java query does not model Spring's "redirect:" view names, its C# query does not model minimal API Results.Redirect, its Go query ignores values read from POST form fields, and .NET's CA3007 only knows System.Web. Each card's scannerRules records what was run and what it found. A dynamic check such as ZAP's External Redirect rule (20019) exercises the running app.
Identifiers
CWE-601 (URL Redirection to Untrusted Site, 'Open Redirect') is in OWASP Top 10:2025 A01 Broken Access Control. CAPEC is left empty on purpose: the only attack pattern MITRE links to CWE-601 is CAPEC-178, Cross-Site Flashing, which is about Flash files. ASVS 5.0.0 requirement 3.7.2 (level 2) allows automatic redirects to hosts the application does not control only when the destination is on an allowlist; 3.7.3 (level 3, a notice page before the user leaves the site) is not implemented in these cards.
References
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- OWASP Top 10:2025 A01 Broken Access Control
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
- WHATWG URL Standard: basic URL parser
- WHATWG Fetch Standard: HTTP-redirect fetch (a Location that is not http or https is a network error)
- OWASP ASVS 5.0.0, V3.7 Other Browser Security Considerations (3.7.2, 3.7.3)
- RFC 9700: OAuth 2.0 Security Best Current Practice, 4.11 Open Redirection
- ZAP alert 20019: External Redirect
Practice in the browser
- Open redirect lab: Return users to this site only · Foundation, JavaScript
- OAuth redirect and PKCE lab: Match redirect URIs and verify PKCE · Advanced, JavaScript
Guides on this weakness
OAuth 2.0 and OpenID Connect login: authorization code with PKCE
Implement OAuth 2.0 and OpenID Connect login with the authorization code flow and PKCE, keep each transaction bound to its browser, and block redirect, mix-up, code-injection and token-theft attacks.
Redirect users only to destinations you control
Check every redirect target taken from a request, so attackers cannot borrow your domain for phishing, carry OAuth tokens off-site or steer a server-side fetcher through your redirects.
Defend OAuth and OIDC against redirect and token attacks
Apply RFC 9700 and RFC 10017: exact redirect matching, PKCE with downgrade protection, state and iss binding, refresh-token rotation, sender-constrained tokens, and a backend-for-frontend so browser tokens cannot be stolen.
Consume third-party APIs safely (OWASP API10:2023)
Treat every upstream and third-party API response as untrusted input: validate it against a schema, set connect and read timeouts, cap the response size, refuse to follow redirects blindly, keep TLS verification on, and scope the credentials you hold for each provider.