Cyber Security

Open Redirects in Small PHP Applications - Let the Server Choose the Destination

Open Redirects in Small PHP Applications - Let the Server Choose the Destination

A login form often needs to return someone to the page they originally wanted. A checkout may continue to a receipt, and an admin action may return to a list. A parameter such as next or return_to seems like a convenient way to preserve that flow. The security question is not whether the application may redirect. It is: who is allowed to choose the destination?

If request data can supply any destination, a useful navigation feature can become an open redirect. The first URL still belongs to the legitimate application, but its response can send the browser somewhere the application never intended. The safer design is usually not a smarter attempt to recognize every bad URL. It is to give untrusted input less authority.

What makes a redirect open?

CWE-601 describes the core weakness as an application accepting user-controlled input that specifies an external link and using it in a redirect. The OWASP cheat sheet frames it similarly: untrusted input reaches a destination without an adequate decision about whether that destination is valid and appropriate.

In PHP, the shortest vulnerable shape is easy to recognize:

<?php
$next = $_GET['next'] ?? '/';

header('Location: ' . $next);
exit;

The problem is not header() itself. The problem is the uninterrupted path from a request parameter to the Location field. A query parameter is the obvious source, but the same trust question applies to a form field, cookie, request header, database value originally supplied by a user, or response from another service. Moving the value into storage does not make it trusted.

Context matters. A service intentionally designed to redirect to arbitrary links has different requirements from a CMS login flow. CWE explicitly notes that intended behavior affects whether this is a vulnerability. For an ordinary return flow, however, arbitrary cross-origin navigation is usually more authority than the feature needs.

The harm is often about borrowed trust

An attacker can place a legitimate domain at the beginning of a link while arranging for the eventual destination to be an untrusted page. OWASP and CWE identify phishing as a possible consequence: the familiar first domain can make the link look more credible, even though the browser leaves it after the response. This does not mean every open redirect has the same severity or will steal credentials. Its impact depends on where the redirect appears, what users expect, and whether another workflow relies on the destination.

A redirect must also never substitute for authorization. Sending an unauthorized user away from an admin route is not enough if that route performs its action before redirecting, or if another request can reach the same handler directly. The destination endpoint must enforce its own access rules. Navigation controls where the browser goes; authorization decides what the server permits.

Prefer destination names over destination URLs

If a flow has three valid outcomes, accept three identifiers rather than the whole URL space. OWASP and CWE both recommend mapping a short name, ID, or token to a destination controlled by the server. The request may choose among approved options, but it cannot invent a fourth option.

For example, suppose a successful POST may continue to either a dashboard or account-security page:

<?php
$destinations = [
    'dashboard' => '/dashboard',
    'security'  => '/account/security',
];

$key = $_POST['next'] ?? 'dashboard';
$target = $destinations[$key] ?? $destinations['dashboard'];

header('Location: ' . $target, true, 303);
exit;

The example is deliberately narrow. Authentication, CSRF protection, and the action being submitted still need their own controls; they are omitted here so the redirect decision remains visible. An unknown key falls back to a known path. The client never supplies the bytes placed in Location.

This pattern also survives route changes well. The public identifier can remain security while the server-side path changes. In a larger application, named routes can provide the same separation without maintaining a second map in each controller.

When an external handoff is genuinely required

Some applications must leave their own origin: documentation, a payment provider, or another service may be a legitimate destination. A fixed mapping still works when the set is small:

<?php
$externalDestinations = [
    'documentation' => 'https://docs.example.org/start',
    'status'        => 'https://status.example.org/',
];

$key = $_GET['to'] ?? '';

if (!array_key_exists($key, $externalDestinations)) {
    http_response_code(400);
    exit('Unknown destination');
}

header('Location: ' . $externalDestinations[$key], true, 302);
exit;

If destinations must be configured rather than fixed in code, the trust decision moves to that configuration workflow. Restrict who can add a destination, review changes, and store parsed, canonical values rather than treating any submitted string as approved.

For a larger but still controlled set, OWASP recommends parsing with a maintained parser compatible with the component that will use the URL, then comparing the scheme, canonical host, effective port, and any required path constraints against an explicit allowlist. That is more work than checking a hostname string. It also deserves tests for ambiguous input and parser differences. A confirmation page that clearly displays the external destination can make an intentional handoff visible, but it is an additional usability and defense measure, not permission to skip validation.

Why familiar string checks are fragile

A check such as str_starts_with($url, 'https://example.com') asks a string question, not an authority question. A longer hostname, user-information component, unusual slash, backslash, encoding, or parser disagreement can change what a browser considers the destination. OWASP therefore warns against raw prefix and suffix matching.

parse_url() does not turn the input into a trusted URL either. The PHP manual says the function decomposes a URL and is not intended to validate it; partial and invalid URLs can be accepted. It also warns that validating with one parser and consuming with another can create a security problem. The WHATWG URL Standard exists partly to make browser URL parsing interoperable, and its security section cautions that URLs passed between parties can come from untrusted sources.

Parsing is still useful when external URLs are unavoidable. It exposes components that can be compared to policy. But “the parser returned an array” is only a syntax observation. The application must still decide which schemes, hosts, ports, paths, and credentials are acceptable. On PHP versions that provide standard-following URI classes, those classes can reduce parser ambiguity, but they do not invent the application's allowlist.

Keep HTTP mechanics separate from the trust decision

According to RFC 9110, Location carries a URI reference. The status code explains how the response relates to the next request. PHP's header() documentation says a Location field defaults to 302 unless a 201 or another 3xx status has already been set.

It is clearer to choose the status deliberately. In the POST example above, 303 means the selected resource can be retrieved with GET or HEAD, which fits a post-action view. A temporary redirect from a GET endpoint may use 302. Status choice does not repair an untrusted target: a carefully chosen 303 to an attacker-controlled URL is still an open redirect.

PHP must send headers before response output. After scheduling the redirect, execution also continues unless the program stops or returns through a framework response. Calling exit immediately after a simple header() redirect keeps later code from running accidentally. This is control-flow hygiene, distinct from destination validation.

Review the whole redirect path

A useful review starts with redirect sinks and works backward. Search for Location:, framework redirect helpers, and client-side navigation such as location.assign(). Client-side code can reproduce the same trust-boundary mistake, although the source and sink are in the browser rather than PHP.

For each redirect, ask:

  • Can any request field, cookie, stored user value, header, or external response influence the destination?
  • Does the feature need a URL, or would a named destination be enough?
  • If an external URL is necessary, which exact scheme, host, port, and path combinations are approved?
  • Are validation and navigation using compatible URL interpretations?
  • Does an invalid or missing value fail closed to a known local page or an error?
  • Does every destination enforce authorization independently?
  • Is the status code intentional, and does execution stop after the response is committed?

Tests should include every approved identifier, an unknown identifier, an empty value, local and external forms, misleading hostnames, user-information syntax, network-path references beginning with //, backslashes, encoded characters, and overlong input. Use reserved example domains in tests and only test systems you are authorized to assess. The expected result should be explicit: approved destinations redirect exactly as designed; everything else returns an error or a known fallback.

A smaller decision is easier to secure

Redirect security becomes difficult when the application asks, “Is this arbitrary string safe everywhere?” The question becomes much more manageable when it asks, “Is this one of the destinations this flow actually supports?” A server-side map answers that smaller question directly.

Not every dynamic redirect must be removed, and parsing remains necessary for some integrations. The important boundary is that untrusted data should not silently become navigation authority. Choose known destinations first, use component-based allowlists only when the feature truly needs them, and keep authorization at the destination. That design does not depend on recognizing every clever spelling of an unwanted URL.

References