Comment créer une redirection en PHP

Comment créer une redirection en PHP
Réponse rapide

header('Location: /destination') suivi de exit, toujours les deux. Sans code explicite PHP envoie un 302, utilisez 301 pour un déplacement définitif et 303 après le traitement d'un formulaire. Une destination fournie par l'utilisateur doit être validée, sinon vous ouvrez une redirection vers un site tiers.

Rediriger en PHP tient en deux lignes. Ce qui mérite un article, c’est le choix du code HTTP, l’arrêt du script derrière, et la faille de redirection ouverte que produit toute redirection construite à partir d’une donnée envoyée par l’utilisateur.

La redirection minimale

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

Deux points souvent mal compris. D’abord, Location accepte une URL absolue comme un chemin relatif à la racine : les deux fonctionnent sur tous les navigateurs actuels. Ensuite, sans code explicite, PHP envoie un 302 Found.

Choisir le bon code

Code Sens Méthode conservée Quand l’utiliser
301 Déplacé définitivement non (POST devient GET) Une URL a changé pour de bon
302 Trouvé, temporaire non (POST devient GET) Défaut historique, à éviter d’écrire
303 Voir ailleurs non, jamais Après un formulaire traité
307 Temporaire oui Maintenance, bascule provisoire
308 Permanent oui URL changée, en préservant POST
php
// Une page a déménagé définitivement
header('Location: https://exemple.com/nouvelle-adresse', true, 301);
exit;

// Après le traitement d'un formulaire : évite le renvoi du POST au rechargement
header('Location: /merci', true, 303);
exit;

Le 303 après formulaire porte un nom, Post/Redirect/Get. Sans lui, un rafraîchissement de page redemande au navigateur de renvoyer le formulaire, et la commande part deux fois.

Le 301 se met en cache dans le navigateur, parfois pour très longtemps. Une redirection permanente posée par erreur est pénible à retirer : les visiteurs qui l’ont reçue continuent d’être redirigés sans même interroger le serveur. Dans le doute, commencer par un 302 ou un 307.

Toujours arrêter le script

header() place un en-tête, il n’interrompt rien. Le code qui suit s’exécute et son contenu part dans le corps de la réponse.

fuite.php
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT";   // parti quand même

Mesuré avec Apache et 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 »

Le navigateur suit la redirection et n’affiche pas ce corps, ce qui rend le problème invisible. Mais il est bien transmis : curl, un proxy, un journal intermédiaire ou un robot le voient. Sur une page qui redirige un visiteur non authentifié, c’est exactement le contenu qu’on voulait protéger qui part sur le réseau.

die() et exit() sont la même chose

Une affirmation circule beaucoup, y compris dans la version précédente de cet article : die() fermerait la connexion et exit() la garderait ouverte. C’est faux. Ce sont deux graphies de la même construction de langage, et la documentation PHP le dit explicitement.

Vérification, deux fichiers identiques à un mot près, servis par le même 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

Les deux réponses sont identiques octet pour octet, en-tête Date mis à part. Choisir entre les deux est une affaire de style. exit est plus courant dans le code moderne, et l’écrire sans parenthèses fonctionne aussi.

Le piège des en-têtes déjà envoyés

C’est l’erreur la plus fréquente sur ce sujet. Le moindre octet écrit avant header(), un espace avant <?php, une ligne vide après ?> dans un fichier inclus, un echo de débogage, un octet BOM en tête de fichier, rend la redirection 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

Le message indique le fichier et la ligne coupables entre parenthèses. Pour tester avant d’agir :

php
if (headers_sent($fichier, $ligne)) {
    error_log("Sortie déjà commencée dans {$fichier}:{$ligne}");
    // repli : redirection côté navigateur
    echo '<meta http-equiv="refresh" content="0;url=/nouvelle-page">';
    exit;
}

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

Deux règles évitent le problème à la source : ne jamais fermer un fichier PHP par ?>, et ne rien afficher avant d’avoir décidé de la réponse.

La redirection ouverte

C’est la vraie faille de sécurité de ce sujet, et elle est absente de la plupart des tutoriels. Le motif « revenir à la page précédente après connexion » se code souvent ainsi :

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

Un attaquant envoie alors un lien de la forme https://votre-site.com/connexion.php?retour=https://site-pirate.example. Le lien porte votre nom de domaine, il passe les filtres de messagerie et rassure la victime, qui atterrit sur une copie de votre page de connexion. C’est le mécanisme de base d’une bonne partie des campagnes d’hameçonnage.

La correction ne consiste pas à filtrer les caractères, mais à n’accepter que des destinations internes.

src/redirection.php
<?php

declare(strict_types=1);

/**
 * N'autorise qu'un chemin interne. Toute URL absolue est écartée.
 */
function destinationSure(string $demande, string $defaut = '/'): string
{
    // Caractères de contrôle : tentative d'injection d'en-tête
    if ($demande === '' || preg_match('/[\x00-\x1F\x7F]/', $demande) === 1) {
        return $defaut;
    }

    // Un « / » unique en tête, non suivi d'un « / » ni d'un « \ »
    if (!preg_match('#^/(?![/\\\\])#', $demande)) {
        return $defaut;
    }

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

    // Pas de remontée de chemin
    if (str_contains($parties['path'] ?? '', '../')) {
        return $defaut;
    }

    return $demande;
}

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

Notre première version de cette fonction ne testait que le double slash de tête. Les essais ont montré qu’elle laissait passer trois familles d’entrées, ce qui justifie chacune des lignes ajoutées :

  • /site-pirate.example, les navigateurs traitent l’antislash comme un slash dans cette position. Le contournement le plus courant d’un contrôle qui ne regarde que //.
  • /okrnX-Injecte: 1header() le refuserait, mais mieux vaut écarter la valeur avant d’arriver là.
  • /ok/../../admin, une remontée de chemin qui atteint une zone non prévue.

Les quinze entrées passées à la version finale :

Valeur reçue 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 et /ok/../../admin /
/okrnX-Injecte: 1 /
mon-compte (sans slash) et chaîne vide /

Si des destinations externes sont réellement nécessaires, la seule forme sûre est une liste blanche de domaines, comparée sur l’hôte extrait par parse_url(), jamais par str_contains().

L’injection dans l’en-tête

Un retour à la ligne dans une valeur d’en-tête permettait autrefois d’en ajouter d’autres. PHP filtre les retours à la ligne dans header() depuis la version 5.1.2 et lève une erreur. Rien à faire de plus, mais c’est une raison supplémentaire de ne pas construire les en-têtes par concaténation de données utilisateur.

Quand ne pas rediriger en PHP

Une redirection permanente d’URL à URL n’a pas besoin de PHP. La faire au niveau du serveur est plus rapide, la requête ne réveille pas l’interpréteur, et survit à une panne de l’application.

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

# Forcer 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 reste indiqué quand la destination dépend d’un état : session, droits, résultat d’un traitement, langue négociée.

À retenir

  • header('Location: …') puis exit, toujours les deux.
  • 303 après un formulaire, 301 seulement quand c’est définitif.
  • die() et exit() sont identiques, mesuré et vérifié.
  • Une destination venue de l’utilisateur se valide contre les chemins internes, en refusant les URL absolues et le double slash.

Le 303 après formulaire va souvent de pair avec un accusé de réception : voir envoyer un e-mail avec PHP. Si la destination doit être calculée côté navigateur plutôt que côté serveur, voir passer des variables de PHP à JavaScript. Pour la redirection qui suit un envoi de fichier, voir uploader un fichier en PHP.

Voir aussi le hub Développement web et la connexion à une base de données en PHP.

Erreurs fréquentes

die() et exit() seraient différents Affirmation fausse, présente dans la version précédente de cet article. Ce sont deux graphies de la même construction. Deux fichiers identiques à ce mot près renvoient des réponses HTTP identiques, en-tête Date mis à part.
header() sans exit header() n'interrompt rien. Le contenu qui suit part dans le corps de la réponse : invisible dans le navigateur, bien présent pour curl, un proxy ou un robot.
Redirection ouverte header('Location: ' . $_GET['retour']) permet de fabriquer un lien d'hameçonnage portant votre nom de domaine. La destination doit être validée comme chemin interne.
Contournement par le double slash //site-pirate.example est une URL relative au protocole. Et /site-pirate.example passe aussi : les navigateurs traitent l'antislash comme un slash à cette position.
En-têtes déjà envoyés Un espace avant <?php, une ligne vide après ?> ou un octet BOM suffisent. Ne jamais fermer un fichier PHP par ?>.
301 mis en cache Une redirection permanente posée par erreur reste dans le navigateur des visiteurs qui l'ont reçue. Dans le doute, un 302 ou un 307.

HTTPPHPSécuritéSEO

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.