PHP redirect: header(), status codes and open redirects

PHP redirect: header(), status codes and open redirects
Quick answer

header('Location: /destination') followed by exit, always both. With no explicit status code PHP sends a 302, use 301 for a permanent move and 303 after a form has been processed. A destination supplied by the user has to be validated, otherwise you are opening a redirect towards a third-party site.

Redirecting in PHP takes two lines. What deserves an article is the choice of HTTP status code, stopping the script behind it, and the open redirect that any redirection built from user-supplied data creates.

The minimal redirect

php
<?php
header('Location: /nouvelle-page');
exit;

Two points are often misunderstood. First, Location accepts an absolute URL just as well as a path relative to the root: both work in every current browser. Second, with no explicit status code, PHP sends a 302 Found.

Choosing the right status code

Code Meaning Method preserved When to use it
301 Moved permanently no (POST becomes GET) A URL has changed for good
302 Found, temporary no (POST becomes GET) The historical default, better not written
303 See other no, never After a form has been processed
307 Temporary yes Maintenance, a temporary switch
308 Permanent yes URL changed, while preserving POST
php
// A page has moved permanently
header('Location: https://exemple.com/nouvelle-adresse', true, 301);
exit;

// After processing a form: avoids resending the POST on reload
header('Location: /merci', true, 303);
exit;

The 303 after a form has a name, Post/Redirect/Get. Without it, refreshing the page asks the browser to send the form again, and the order goes through twice.

A 301 is cached by the browser, sometimes for a very long time. A permanent redirect put in place by mistake is painful to remove: visitors who received it keep being redirected without even asking the server. When in doubt, start with a 302 or a 307.

Always stop the script

header() sets a header, it interrupts nothing. The code that follows still runs, and its output goes into the body of the response.

fuite.php
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT";   // sent anyway

Measured with Apache and PHP 8.5.10:

code
avec exit  -> corps de la réponse : 0 octet
sans exit  -> corps de la réponse : 16 octets, « CONTENU QUI FUIT »

The browser follows the redirect and never displays that body, which makes the problem invisible. It is transmitted all the same: curl, a proxy, an intermediate log or a bot all see it. On a page that redirects an unauthenticated visitor, what goes out on the network is exactly the content you meant to protect.

die() and exit() are the same thing

One claim gets passed around a lot, including in the previous version of this article: die() supposedly closes the connection while exit() leaves it open. That is false. They are two spellings of the same language construct, and the PHP documentation says so explicitly.

Checked with two files identical apart from one word, served by the same Apache:

php
<?php header('HTTP/1.1 304 Not Modified'); exit();   // avec-exit.php
<?php header('HTTP/1.1 304 Not Modified'); die();    // avec-die.php
code
$ curl -s -D - -o /dev/null http://localhost/avec-exit.php
HTTP/1.1 304 Not Modified
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.5.10

$ curl -s -D - -o /dev/null http://localhost/avec-die.php
HTTP/1.1 304 Not Modified
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.5.10

The two responses are identical byte for byte, the Date header aside. Choosing between them is a matter of style. exit is more common in modern code, and writing it without parentheses works too.

The headers-already-sent trap

This is the most frequent error on the subject. The slightest byte written before header(), a space before <?php, a blank line after ?> in an included file, a debugging echo, a BOM byte at the head of the file, makes the redirect impossible.

code
Warning: Cannot modify header information - headers already sent by
(output started at /var/www/inc/config.php:42) in /var/www/page.php on line 3

The message names the offending file and line in brackets. To test before acting:

php
if (headers_sent($fichier, $ligne)) {
    error_log("Sortie déjà commencée dans {$fichier}:{$ligne}");
    // fallback: redirect on the browser side
    echo '<meta http-equiv="refresh" content="0;url=/nouvelle-page">';
    exit;
}

header('Location: /nouvelle-page', true, 303);
exit;

Two rules avoid the problem at source: never close a PHP file with ?>, and print nothing before you have decided what the response will be.

The open redirect

This is the real security hole on this subject, and it is missing from most tutorials. The “go back to the previous page after logging in” pattern is often written like this:

php
// Vulnerable
header('Location: ' . $_GET['retour']);
exit;

An attacker then sends a link of the form https://votre-site.com/connexion.php?retour=https://site-pirate.example. The link carries your domain name, it gets past mail filters and reassures the victim, who lands on a copy of your login page. This is the basic mechanism behind a good share of phishing campaigns.

The fix is not to filter characters, but to accept internal destinations only.

src/redirection.php
<?php

declare(strict_types=1);

/**
 * Only allows an internal path. Any absolute URL is rejected.
 */
function destinationSure(string $demande, string $defaut = '/'): string
{
    // Control characters: header injection attempt
    if ($demande === '' || preg_match('/[\x00-\x1F\x7F]/', $demande) === 1) {
        return $defaut;
    }

    // A single leading “/”, not followed by a “/” or a backslash
    if (!preg_match('#^/(?![/\\\\])#', $demande)) {
        return $defaut;
    }

    $parties = parse_url($demande);
    if ($parties === false || isset($parties['scheme']) || isset($parties['host'])) {
        return $defaut;
    }

    // No path traversal
    if (str_contains($parties['path'] ?? '', '../')) {
        return $defaut;
    }

    return $demande;
}

function redirigerVers(string $demande, string $defaut = '/'): never
{
    header('Location: ' . destinationSure($demande, $defaut), true, 303);
    exit;
}

Our first version of this function only tested the leading double slash. Testing showed that it let three families of input through, which justifies every line that was added:

  • /site-pirate.example, browsers treat the backslash as a slash in this position. The most common way round a check that only looks at //.
  • /okrnX-Injecte: 1header() would refuse it, but it is better to discard the value before getting that far.
  • /ok/../../admin, a path traversal that reaches an area never meant to be reachable.

The fifteen inputs put through the final version:

Value received Destination
/mon-compte /mon-compte
/articles?page=2 /articles?page=2
/a/b?x=1#ancre /a/b?x=1#ancre
https://site-pirate.example /
//site-pirate.example /
/site-pirate.example /
javascript:alert(1) /
/../admin and /ok/../../admin /
/okrnX-Injecte: 1 /
mon-compte (no leading slash) and empty string /

If external destinations really are needed, the only safe form is a whitelist of domains, compared against the host extracted by parse_url(), never with str_contains().

Header injection

A line break in a header value once made it possible to add more headers. PHP has been filtering line breaks in header() since version 5.1.2 and raises an error instead. Nothing more to do about it, but it is one more reason not to build headers by concatenating user data.

When not to redirect in PHP

A permanent URL-to-URL redirect does not need PHP. Doing it at server level is faster, the request never wakes the interpreter, and it survives an outage of the application.

.htaccess
Redirect 301 /ancienne-page /nouvelle-page

# Force HTTPS
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
nginx.conf
location = /ancienne-page {
    return 301 /nouvelle-page;
}

PHP is still the right place when the destination depends on state: session, permissions, the result of some processing, negotiated language.

Key points

  • header('Location: …') then exit, always both.
  • 303 after a form, 301 only when the move is permanent.
  • die() and exit() are identical, measured and verified.
  • A destination coming from the user is validated against internal paths, refusing absolute URLs and the double slash.

The 303 after a form often goes hand in hand with a confirmation message: see sending an email with PHP. If the destination has to be worked out in the browser rather than on the server, see passing variables from PHP to JavaScript. For the redirect that follows a file upload, see uploading a file in PHP.

See also the Web development hub and connecting to a database in PHP.

Common errors

die() and exit() believed to differ A false claim, present in the previous version of this article. They are two spellings of the same construct. Two files identical apart from that word return identical HTTP responses, the Date header aside.
header() without exit header() interrupts nothing. The content that follows goes into the body of the response: invisible in the browser, very much there for curl, a proxy or a bot.
Open redirect header('Location: ' . $_GET['retour']) lets an attacker craft a phishing link carrying your domain name. The destination must be validated as an internal path.
Getting round the check with a double slash //site-pirate.example is a protocol-relative URL. And /site-pirate.example gets through too: browsers treat the backslash as a slash in that position.
Headers already sent A space before <?php, a blank line after ?> or a BOM byte is enough. Never close a PHP file with ?>.
301 cached A permanent redirect put in place by mistake stays in the browser of every visitor who received it. When in doubt, a 302 or a 307.

HTTPPHPSécuritéSEO

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.