Escribes usuario y contraseña, entras, y en la página siguiente el sitio ya
sabe quién eres. Parece obvio, y no lo es en absoluto: cada petición al servidor
es independiente de la anterior.
El servidor no recuerda nada entre una página y otra. Atiende, responde y olvida. Si
no se hace algo, la segunda página no tiene forma de saber que la primera te dejó
entrar.
Cómo se resuelve: la sesión
El servidor abre un expediente
Al llamar a session_start(), PHP crea un espacio en el
servidor para ese visitante y le pone un identificador largo y
aleatorio.
Le entrega una ficha al navegador
Ese identificador -y solo eso- viaja al navegador en una cookie. Los
datos se quedan en el servidor.
El navegador la enseña cada vez
En cada petición siguiente el navegador manda la cookie, PHP busca el
expediente y te devuelve tus datos en $_SESSION.
Y por eso hay que proteger la ficha
Quien consiga ese identificador es tratado como tú. De ahí las
precauciones que vienen a continuación, y de ahí que todo esto se sirva por
HTTPS.
Guardar y leer un dato de la sesión
<?php
session_start();
$_SESSION['usuario_id'] = 42;
$_SESSION['nombre'] = 'Ana Gómez';
// En cualquier otra página del sitio:
session_start();
echo 'Hola, ' . htmlspecialchars($_SESSION['nombre'] ?? 'visitante');
session_start() va antes de imprimir nada. Tiene
que enviar la cookie, y las cookies viajan en las cabeceras, que salen antes que el
contenido.
Si ves «headers already sent», ya sabes de la lección 6 dónde mirar: un espacio o un
salto de línea detrás de un ?> en algún archivo incluido.
La contraseña: no se guarda, se guarda su huella
Ahora la parte que hay que hacer bien a la primera. Una contraseña
nunca se guarda tal como el usuario la escribió, y tampoco «cifrada» de
forma que se pueda recuperar.
Se guarda un hash: un resultado del que no se puede volver atrás. Con
él se puede comprobar si una contraseña coincide, pero no se puede leer cuál era. Si
alguien se lleva tu base de datos, no se lleva las contraseñas de tus usuarios.
Registrar: guardar la huella
<?php
$clave = $_POST['clave'] ?? '';
$hash = password_hash($clave, PASSWORD_DEFAULT);
// Esto es lo que se guarda en la base de datos:
echo $hash;
<?php
session_start();
$correo = trim($_POST['correo'] ?? '');
$clave = $_POST['clave'] ?? '';
$usuario = buscarUsuarioPorCorreo($correo); // lo veremos en la lección 8
if ($usuario !== null && password_verify($clave, $usuario['clave_hash'])) {
session_regenerate_id(true);
$_SESSION['usuario_id'] = $usuario['id'];
header('Location: /panel.php');
exit;
}
// Un solo mensaje para los dos casos, a propósito.
$error = 'Correo o contraseña incorrectos.';
Las cuatro decisiones de ese archivo
password_verify, nunca ===
El hash lleva dentro el algoritmo, el coste y la sal, así que
password_verify() sabe repetir el cálculo y comparar.
Compararlo tú con === no funciona: dos hashes de la misma
contraseña son distintos, y esa es justamente la idea.
session_regenerate_id(true)
Cambia el identificador de sesión en el momento de
entrar.
Si alguien logró fijarle a la víctima un identificador conocido antes del
login, al cambiarlo se queda con uno que ya no sirve.
El mismo mensaje para los dos fallos
«El correo no existe» le confirma a quien prueba que el otro correo
sí existe. Así se hace una lista de usuarios válidos.
Un solo mensaje para las dos situaciones no da esa pista.
exit después de header
header('Location: ...') pide al navegador que vaya a
otro sitio, pero no detiene el programa.
Sin el exit, el código de abajo se sigue ejecutando y puede
llegar a enviar contenido de la página privada.
Cerrar la zona privada: cada página, por su cuenta
Aquí está el error que más se repite, y lo comete gente con experiencia:
quitar el enlace del menú y dar la página por protegida.
A una dirección se llega escribiéndola. Y también aparece en el historial, en un
correo reenviado o en el buscador. El menú no es una puerta: es una sugerencia.
partes/solo-usuarios.php
<?php
session_start();
if (!isset($_SESSION['usuario_id'])) {
header('Location: /entrar.php');
exit;
}
Y como PRIMERA línea de cada página privada
<?php
require __DIR__ . '/partes/solo-usuarios.php';
// A partir de aquí, con la seguridad de que hay alguien identificado.
$titulo = 'Panel';
require __DIR__ . '/partes/cabecera.php';
La comprobación va arriba del todo, antes de imprimir nada. Si
se hace a mitad del archivo, la parte de arriba de la página privada ya se envió al
visitante antes de la redirección.
Y ese require va en todas las páginas privadas. La que se
olvide queda abierta, y desde fuera se ve exactamente igual que las demás.
Salir
Cerrar sesión no es borrar una variable: hay que vaciar el expediente y tirar la
ficha.
No se la mandas: no la tienes, y eso es exactamente lo
que se buscaba.
Lo que se hace es enviar un enlace con un código de un solo uso y con
caducidad, que permite escribir una nueva. Un sitio que te reenvía tu
contraseña por correo te está diciendo que la guarda legible.
Vi ejemplos con md5() y sha1()
Son de hace quince años y hoy son inseguros para contraseñas: están
diseñados para ser rápidos, y eso es justo lo que ayuda a quien
prueba millones por segundo.
password_hash() usa un algoritmo deliberadamente lento y con un
coste ajustable. Desde PHP 8.4 ese coste por defecto subió de 10 a
12, precisamente porque el hardware mejora.
¿Cuánto dura una sesión?
Depende de la configuración del servidor -del orden de minutos de
inactividad- y por defecto la cookie se borra al cerrar el navegador.
En una aplicación con datos sensibles, además, conviene cerrarla por
inactividad: guardar en la sesión la hora del último movimiento y
expulsar pasado un tiempo.
¿Qué exijo como contraseña?
Longitud, sobre todo. Las recomendaciones actuales van hacia
frases largas y en contra de las reglas de composición -una
mayúscula, un número, un símbolo-, que empujan a la gente hacia patrones
predecibles como Clave2026*.
Lo que sí conviene es rechazar las contraseñas que ya aparecen en las filtraciones
conocidas.
Compruébalo tú mismo
Quitas del menú el enlace a /panel.php para que solo lo
vean los administradores. ¿Está protegida?
Esconder el enlace es diseño, no seguridad. Cada página privada
comprueba la sesión en su primera línea.
¿Por qué no se compara la contraseña con
$hash === password_hash($clave, PASSWORD_DEFAULT)?
Cada hash lleva su propia sal, así que nunca coinciden.
password_verify() lee del hash el algoritmo, el coste y la sal, y
repite el cálculo.
Tu login responde «ese correo no está registrado» cuando el correo no
existe. ¿Qué problema tiene?
Un mensaje único para «no existe» y «contraseña incorrecta» no da
esa información. Es un detalle pequeño con consecuencias reales.
Lo que sigue
Falta una pieza: en esta lección apareció buscarUsuarioPorCorreo()
como si existiera. En la última lección la escribes de verdad, conectando con una base de
datos, y montas con todo lo del curso un proyecto que se puede publicar.