Redirección en PHP: cómo hacerla con header() y exit

Redirección en PHP: cómo hacerla con header() y exit
Respuesta rápida

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
<?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 Mantenimiento, cambio provisional
308 Permanente URL cambiada, conservando el POST
php
// 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.

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

Medido con Apache y 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 »

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
<?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

Las 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.

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

El mensaje indica entre paréntesis el archivo y la línea culpables. Para comprobarlo antes de actuar:

php
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í:

php
// 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.

src/redirection.php
<?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: 1header() 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.

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

# Forzar 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 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 luego exit, siempre los dos.
  • 303 después de un formulario, 301 solo cuando es definitivo.
  • die() y exit() 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

die() y exit() serían diferentes Afirmación falsa, presente en la versión anterior de este artículo. Son dos grafías de la misma construcción. Dos archivos idénticos salvo por esa palabra devuelven respuestas HTTP idénticas, salvo la cabecera Date.
header() sin exit 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.
Redirección abierta header('Location: ' . $_GET['retour']) permite fabricar un enlace de phishing que lleva tu propio nombre de dominio. El destino debe validarse como ruta interna.
Evasión con la doble barra //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.
Cabeceras ya enviadas Basta un espacio antes de <?php, una línea en blanco después de ?> o un byte BOM. No cierres nunca un archivo PHP con ?>.
301 en caché Una redirección permanente puesta por error se queda en el navegador de todos los visitantes que la recibieron. En caso de duda, un 302 o un 307.

HTTPPHPSécuritéSEO

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.