
header('Location: /destination') seguido de exit, siempre los dos. Sin código explícito PHP envía un 302, usa 301 para un traslado definitivo y 303 después de procesar un formulario. Un destino facilitado por el usuario hay que validarlo, o estarás abriendo una redirección hacia un sitio de terceros.
Redirigir en PHP son dos líneas. Lo que merece un artículo es la elección del código HTTP, la parada del script justo después y la brecha de redirección abierta que produce cualquier redirección construida a partir de un dato enviado por el usuario.
La redirección mínima
<?php
header('Location: /nouvelle-page');
exit;Dos puntos que se entienden mal a menudo. Primero, Location acepta tanto una URL absoluta como una ruta relativa a la raíz: las dos funcionan en todos los navegadores actuales. Segundo, sin código explícito, PHP envía un 302 Found.
Elegir el código adecuado
| Código | Significado | Método conservado | Cuándo usarlo |
|---|---|---|---|
| 301 | Movido permanentemente | no (el POST pasa a GET) | Una URL ha cambiado para siempre |
| 302 | Encontrado, temporal | no (el POST pasa a GET) | Valor por defecto histórico, mejor no escribirlo |
| 303 | Ver en otra ubicación | no, nunca | Después de procesar un formulario |
| 307 | Temporal | sí | Mantenimiento, cambio provisional |
| 308 | Permanente | sí | URL cambiada, conservando el POST |
// Una página se ha movido de forma permanente
header('Location: https://exemple.com/nouvelle-adresse', true, 301);
exit;
// Después de procesar un formulario: evita reenviar el POST al recargar
header('Location: /merci', true, 303);
exit;El 303 después de un formulario tiene nombre propio: Post/Redirect/Get. Sin él, al refrescar la página el navegador vuelve a enviar el formulario y el pedido se cursa dos veces.
El 301 se queda en la caché del navegador, a veces durante muchísimo tiempo. Una redirección permanente puesta por error cuesta mucho de retirar: los visitantes que la recibieron siguen siendo redirigidos sin llegar a consultar el servidor. En caso de duda, empieza por un 302 o un 307.
Detener siempre el script
header() coloca una cabecera, no interrumpe nada. El código que viene después se ejecuta y su salida acaba en el cuerpo de la respuesta.
<?php
header('Location: /connexion', true, 301);
echo "CONTENU QUI FUIT"; // enviado igualmenteMedido con Apache y 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 »El navegador sigue la redirección y no muestra ese cuerpo, así que el problema pasa desapercibido. Pero se transmite igual: curl, un proxy, un registro intermedio o un robot lo ven. En una página que redirige a un visitante no autenticado, lo que sale a la red es justo el contenido que queríamos proteger.
die() y exit() son lo mismo
Circula mucho una afirmación, presente incluso en la versión anterior de este artículo: die() cerraría la conexión y exit() la dejaría abierta. Es falso. Son dos grafías de la misma construcción del lenguaje, y la documentación de PHP lo dice de forma explícita.
Comprobación: dos archivos idénticos salvo por una palabra, servidos por el mismo 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.10Las dos respuestas son idénticas byte a byte, salvo la cabecera Date. Elegir entre una y otra es cuestión de estilo. exit es más habitual en el código moderno, y escribirlo sin paréntesis también funciona.
La trampa de las cabeceras ya enviadas
Es el error más frecuente en este tema. El menor byte escrito antes de header(), un espacio antes de <?php, una línea en blanco después de ?> en un archivo incluido, un echo de depuración, un byte BOM al principio del archivo, hace imposible la redirección.
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 3El mensaje indica entre paréntesis el archivo y la línea culpables. Para comprobarlo antes de actuar:
if (headers_sent($fichier, $ligne)) {
error_log("Sortie déjà commencée dans {$fichier}:{$ligne}");
// alternativa: redirección desde el navegador
echo '<meta http-equiv="refresh" content="0;url=/nouvelle-page">';
exit;
}
header('Location: /nouvelle-page', true, 303);
exit;Dos reglas evitan el problema de raíz: no cerrar nunca un archivo PHP con ?> y no imprimir nada antes de haber decidido la respuesta.
La redirección abierta
Es la verdadera brecha de seguridad de este tema, y falta en la mayoría de los tutoriales. El patrón «volver a la página anterior tras iniciar sesión» se suele programar así:
// Vulnerable
header('Location: ' . $_GET['retour']);
exit;Un atacante envía entonces un enlace del tipo https://votre-site.com/connexion.php?retour=https://site-pirate.example. El enlace lleva tu nombre de dominio, pasa los filtros de correo y tranquiliza a la víctima, que aterriza en una copia de tu página de inicio de sesión. Es el mecanismo básico de buena parte de las campañas de phishing.
La corrección no consiste en filtrar caracteres, sino en aceptar únicamente destinos internos.
<?php
declare(strict_types=1);
/**
* Solo permite una ruta interna. Cualquier URL absoluta se descarta.
*/
function destinationSure(string $demande, string $defaut = '/'): string
{
// Caracteres de control: intento de inyección de cabecera
if ($demande === '' || preg_match('/[\x00-\x1F\x7F]/', $demande) === 1) {
return $defaut;
}
// Una única «/» inicial, no seguida de «/» ni de una barra invertida
if (!preg_match('#^/(?![/\\\\])#', $demande)) {
return $defaut;
}
$parties = parse_url($demande);
if ($parties === false || isset($parties['scheme']) || isset($parties['host'])) {
return $defaut;
}
// Sin salto de directorio
if (str_contains($parties['path'] ?? '', '../')) {
return $defaut;
}
return $demande;
}
function redirigerVers(string $demande, string $defaut = '/'): never
{
header('Location: ' . destinationSure($demande, $defaut), true, 303);
exit;
}Nuestra primera versión de esta función solo comprobaba la doble barra inicial. Las pruebas demostraron que dejaba pasar tres familias de entradas, y eso justifica cada una de las líneas añadidas:
/site-pirate.example, los navegadores tratan la barra invertida como una barra normal en esa posición. Es la forma más habitual de sortear un control que solo mira//./okrnX-Injecte: 1—header()lo rechazaría, pero es mejor descartar el valor antes de llegar ahí./ok/../../admin, un salto de directorio que alcanza una zona no prevista.
Las quince entradas que pasaron por la versión final:
| Valor recibido | Destino |
|---|---|
/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 y /ok/../../admin | / |
/okrnX-Injecte: 1 | / |
mon-compte (sin barra) y cadena vacía | / |
Si de verdad hacen falta destinos externos, la única forma segura es una lista blanca de dominios, comparada sobre el host que extrae parse_url(), nunca con str_contains().
La inyección en la cabecera
Un salto de línea dentro del valor de una cabecera permitía antaño añadir otras. PHP filtra los saltos de línea en header() desde la versión 5.1.2 y lanza un error. No hay nada más que hacer, pero es una razón añadida para no construir las cabeceras concatenando datos del usuario.
Cuándo no redirigir desde PHP
Una redirección permanente de URL a URL no necesita PHP. Hacerla en el servidor es más rápido, la petición no despierta al intérprete, y sobrevive a una caída de la aplicación.
Redirect 301 /ancienne-page /nouvelle-page
# Forzar HTTPS
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]location = /ancienne-page {
return 301 /nouvelle-page;
}PHP sigue siendo la opción adecuada cuando el destino depende de un estado: sesión, permisos, resultado de un proceso, idioma negociado.
Para recordar
header('Location: …')y luegoexit, siempre los dos.- 303 después de un formulario, 301 solo cuando es definitivo.
die()yexit()son idénticos, medido y comprobado.- Un destino que llega del usuario se valida contra las rutas internas, rechazando las URL absolutas y la doble barra.
El 303 después de un formulario suele ir acompañado de un acuse de recibo: mira enviar un correo con PHP. Si el destino se calcula en el navegador y no en el servidor, mira pasar variables de PHP a JavaScript. Para la redirección que sigue a la subida de un archivo, mira subir un archivo en PHP.
Consulta también el hub de Desarrollo web y la conexión a una base de datos en PHP.
Errores frecuentes
Date.header() no interrumpe nada. El contenido que sigue sale en el cuerpo de la respuesta: invisible en el navegador, bien presente para curl, un proxy o un robot.header('Location: ' . $_GET['retour']) permite fabricar un enlace de phishing que lleva tu propio nombre de dominio. El destino debe validarse como ruta interna.//site-pirate.example es una URL relativa al protocolo. Y /site-pirate.example también pasa: los navegadores tratan la barra invertida como una barra normal en esa posición.<?php, una línea en blanco después de ?> o un byte BOM. No cierres nunca un archivo PHP con ?>.

