---
key: "open-redirect"
title: "Redirect users only to destinations you control"
url: "https://softwaresecurity.com/domains/appsec/secure-features/open-redirect/"
api: "https://softwaresecurity.com/api/v1/articles/open-redirect.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-601","A01:2025","V3","V10","WSTG-CLNT-04"]
tags: ["Server-side request forgery (SSRF)","Input validation and parsing","Account takeover","OAuth and OpenID Connect"]
stages: ["design","build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

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

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-features/open-redirect/

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

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

Audience: Developers and reviewers who build login, logout, SSO and link-handling flows

Before you start:

- Read URLs and HTTP redirect responses.
- Know the basic OAuth or OIDC login flow.

Time: About 38 minutes: 11 to read, 5 on the code and 22 for the exercise

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

```js
// 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.

```http
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.

```js
// 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.

| 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` |

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

```js
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.

```python
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.

```java
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.

```js
// 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).

```js
// 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 material

```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. Classify S1

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

Worked answer: 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. Classify S2

Run P3 and P4 through S2.

Worked answer: 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. Spot the decode bug

Run P5 through S3.

Worked answer: `/%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`.

Worked answer: 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. 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.

Worked answer: 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.

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.

Scope: 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.

## Executable practice

- [Open redirect lab: Return users to this site only](https://softwaresecurity.com/labs/safe-redirect/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [MITRE CWE-601: URL Redirection to Untrusted Site ('Open Redirect')](https://cwe.mitre.org/data/definitions/601.html). CWE 4.20.
- [OWASP Unvalidated Redirects and Forwards Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html)
- [RFC 9700: OAuth 2.0 Security BCP, 4.1 Insufficient Redirect URI Validation](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.1). January 2025.
- [RFC 9700: OAuth 2.0 Security BCP, 4.11 Open Redirection](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.11). January 2025.
- [WHATWG URL Standard: basic URL parser](https://url.spec.whatwg.org/#concept-basic-url-parser)
- [OWASP ASVS 5.0.0, V3.7 Other Browser Security Considerations](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x12-V3-Web-Frontend-Security.md#v37-other-browser-security-considerations). Version 5.0.0.
- [Microsoft Learn: Prevent open redirect attacks in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects?view=aspnetcore-10.0). ASP.NET Core 10.0.
- [Django 6.1: LoginView.success_url_allowed_hosts](https://docs.djangoproject.com/en/6.1/topics/auth/default/#django.contrib.auth.views.LoginView.success_url_allowed_hosts)
- [CodeQL query help: URL redirection from remote source (py/url-redirection)](https://codeql.github.com/codeql-query-help/python/py-url-redirection/)
- [OWASP API Security Top 10 2023: API7 Server Side Request Forgery](https://api-security.owasp.org/editions/2023/en/0xa7-server-side-request-forgery/)
- [NVD: CVE-2019-3778, open redirect in Spring Security OAuth](https://nvd.nist.gov/vuln/detail/cve-2019-3778)