In short

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, includes or 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.

Verify it

  1. List every redirect sink: res.redirect, sendRedirect, HttpResponseRedirect or redirect(), Response.Redirect, raw Location headers, location.href or location.assign, window.open and meta refresh. Mark each one whose target comes from a request, a cookie or stored user data.
  2. Send the bypass payloads from this guide to each sink and resolve the Location header against the request URL; every result must be your own origin or your fallback page.
  3. 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.
  4. 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.
  5. 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

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 38 minutes: 11 to read, 5 on the code and 22 for the exercise

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.
Go to the guided practice

An open redirect lends your domain to someone else's link

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.

Vulnerablelogin.jsJavaScript
// Vulnerable: the browser goes wherever ?next= points
app.get('/login/done', (req, res) => {
  res.redirect(303, req.query.next);
});

Phishing with your login page as the lure

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.

Find every redirect sink, not just the login page

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, OIDC post_logout_redirect_uri, and OAuth redirect_uri and 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, calls location.assign, location.replace or window.open, or writes a meta refresh.
  • Framework helpers that redirect back to the Referer header or to a URL named in a parameter.

OAuth: an open redirect on the client leaks tokens

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.

Token carried off-site through the client's open redirectHTTP
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=2YotnFZFEjr1zCsicMWpAA

Chained with SSRF or a client-side redirect

Server-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.

VulnerableClient-side redirectJavaScript
// Vulnerable: ?next=javascript:... runs in your origin; ?next=//evil.example leaves it
const next = new URLSearchParams(location.search).get('next');
location.href = next;

Why string checks fail

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.

Why string checks fail
Value of nextCheck it passesWhere 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 unencodedThe same prefix checkshttps://evil.example/ (the tab is deleted)
///evil.example/Python urlsplit and Java URI both report no hosthttps://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 Pythonhttps://evil.example/
/%2Fevil.example/ checked, then decoded againAny check made before a second decodehttps://evil.example/ once it becomes //evil.example/
/.//evil.example/Every check here; it is safe as sentStays 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 hostRuns script when assigned to location.href

Accept only same-origin paths (Node.js)

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.

Fixedredirect.jsJavaScript
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));
});

The same rule in Python

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.

Fixedapp.pyPython
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)

The same rule in Java and Spring

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.

FixedLoginRedirectController.javaJava
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();
  }
}

Off-site destinations: IDs or exact origins

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.

Fixedallowlist.jsJavaScript
// 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/' };

Use framework helpers, and know what they check

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.

Pitfalls

Most real open redirects have a check; it just tests the wrong thing.

  • Validating one value and redirecting another: checking next but sending returnTo, or checking before a second decode.
  • Building absolute URLs from the Host or X-Forwarded-Host header, which the client controls unless a trusted proxy overwrites it.
  • Redirecting back to the Referer header 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, Refresh headers and window.open calls 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.

A real-world failure

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.

Test every redirect sink with bypass payloads

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).

Testcheck-redirects.mjsJavaScript
// 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
Plain text
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=2
  1. 1. Classify S1

    Run P1, P2 and P6 through S1. State the Location header and where a current browser ends up.

    Expected result and reasoning

    P1 produces Location: //evil.test/ and the browser leaves for evil.test. P2 passes the prefix check, and the browser reads the backslash as a slash, so it also leaves. P6 stays on app.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. 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.test and P4's host is evil.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. 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. 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_token to evil.test. Register the exact callback URI, use the code flow and fix S1.

  5. 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.

Your deliverable: A table of S1 to S3 and the two integrations showing each payload's outcome before and after your fix, plus the replacement validation rule.

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

Open redirect lab: Return users to this site only

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.

  1. MITRE CWE-601: URL Redirection to Untrusted Site ('Open Redirect'). CWE 4.20.
  2. OWASP Unvalidated Redirects and Forwards Cheat Sheet
  3. RFC 9700: OAuth 2.0 Security BCP, 4.1 Insufficient Redirect URI Validation. January 2025.
  4. RFC 9700: OAuth 2.0 Security BCP, 4.11 Open Redirection. January 2025.
  5. WHATWG URL Standard: basic URL parser
  6. OWASP ASVS 5.0.0, V3.7 Other Browser Security Considerations. Version 5.0.0.
  7. Microsoft Learn: Prevent open redirect attacks in ASP.NET Core. ASP.NET Core 10.0.
  8. Django 6.1: LoginView.success_url_allowed_hosts
  9. CodeQL query help: URL redirection from remote source (py/url-redirection)
  10. OWASP API Security Top 10 2023: API7 Server Side Request Forgery
  11. NVD: CVE-2019-3778, open redirect in Spring Security OAuth

Reference information

OWASP WSTG v4.2 tests
WSTG-CLNT-04

Download this guide as Markdown