
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
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 |
// 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.
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT"; // sent anywayMeasured with Apache and PHP 8.5.10:
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 header('HTTP/1.1 304 Not Modified'); exit(); // avec-exit.php
<?php header('HTTP/1.1 304 Not Modified'); die(); // avec-die.php$ 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.10The 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.
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 3The message names the offending file and line in brackets. To test before acting:
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:
// 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.
<?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: 1—header()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.
Redirect 301 /ancienne-page /nouvelle-page
# Force HTTPS
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]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: …')thenexit, always both.- 303 after a form, 301 only when the move is permanent.
die()andexit()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
Date header aside.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.header('Location: ' . $_GET['retour']) lets an attacker craft a phishing link carrying your domain name. The destination must be validated as an internal path.//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.<?php, a blank line after ?> or a BOM byte is enough. Never close a PHP file with ?>.

