Módulo 09: Sesiones y cookies
Objetivos del módulo
- Entender por qué HTTP “no tiene memoria” y cómo se soluciona
- Crear, leer y borrar cookies con
setcookie()y su array de opciones - Conocer los atributos de seguridad de las cookies:
Secure,HttpOnlyySameSite - Trabajar con sesiones:
session_start(),$_SESSION,session_id() - Comprender qué ocurre por debajo con la cookie
PHPSESSID - Regenerar el identificador de sesión y destruir la sesión por completo
- Configurar las sesiones de forma segura
- Construir un login/logout con
password_hash()ypassword_verify() - Proteger páginas, mostrar mensajes flash y crear un carrito de la compra
- Entender cómo funciona la opción “recordarme” con tokens
1. HTTP no tiene estado
📘 Concepto: HTTP es un protocolo sin estado (stateless): cada petición es independiente y el servidor no recuerda nada de las anteriores. Para el servidor, tu segunda visita es la de un completo desconocido, igual que la primera.
Imagina una cafetería donde el camarero tiene amnesia total: cada vez que te acercas a la barra no recuerda que acabas de pedir, ni tu nombre, ni que ya has pagado. Para que el negocio funcione, la solución es darte una tarjeta de cliente con un número: cada vez que vuelves la enseñas, y el camarero consulta en su libreta qué hay apuntado con ese número.
En la web:
- La tarjeta es una cookie que guarda el navegador.
- La libreta del camarero es la sesión, guardada en el servidor.
Sin este mecanismo no existirían el login, los carritos de la compra ni las preferencias de idioma.
| Mecanismo | Dónde se guardan los datos | Quién puede verlos/modificarlos | Tamaño | Uso típico |
|---|---|---|---|---|
| Cookie | En el navegador | El usuario (puede leerlas y cambiarlas) | ~4 KB por cookie | Preferencias, idioma, identificador de sesión |
| Sesión | En el servidor | Solo el servidor | Lo que permita el servidor | Usuario autenticado, carrito, mensajes |
2. Cookies
📘 Concepto: Una cookie es un pequeño fragmento de texto (nombre = valor) que el servidor pide al navegador que guarde. A partir de ese momento, el navegador la reenvía automáticamente en cada petición a ese sitio.
sequenceDiagram
participant N as Navegador
participant S as Servidor
N->>S: GET /index.php
S-->>N: 200 OK<br/>Set-Cookie: idioma=es; Path=/; Max-Age=2592000
Note left of N: Guarda la cookie
N->>S: GET /productos.php<br/>Cookie: idioma=es
Note right of S: $_COOKIE['idioma'] = 'es'
S-->>N: 200 OK (página en español)
Crear una cookie con setcookie()
Desde PHP 7.3, la forma recomendada es pasar las opciones en un array:
<?php
declare(strict_types=1);
setcookie('idioma', 'es', [
'expires' => time() + 60 * 60 * 24 * 30, // Caduca en 30 días
'path' => '/', // Válida en todo el sitio
'domain' => '', // Solo el dominio actual
'secure' => true, // Solo por HTTPS
'httponly' => true, // JavaScript no puede leerla
'samesite' => 'Lax', // Protección frente a CSRF
]);
| Opción | Significado | Valor recomendado |
|---|---|---|
expires | Momento (timestamp Unix) en que caduca. 0 = cookie de sesión del navegador (se borra al cerrarlo) | Según el caso |
path | Rutas del sitio en las que se envía | '/' |
domain | Dominio (y subdominios) en los que se envía. Vacío = solo el dominio exacto | '' salvo que necesites subdominios |
secure | Solo se envía por HTTPS | true en producción |
httponly | Invisible para JavaScript (document.cookie) | true casi siempre |
samesite | Si se envía en peticiones desde otros sitios: Strict, Lax o None | 'Lax' (o 'Strict') |
Valores de SameSite
| Valor | Comportamiento |
|---|---|
Strict | Solo se envía si la petición se origina en tu propio sitio. Ni siquiera al llegar desde un enlace externo |
Lax | Se envía en navegación normal (clic en un enlace desde otro sitio) pero no en POST ni en peticiones incrustadas desde otros sitios. Es el valor por defecto en los navegadores actuales |
None | Se envía siempre, también desde otros sitios. Exige Secure |
⚠️ Importante:
setcookie()envía una cabeceraSet-Cookie, así que debe llamarse antes de cualquier salida (igual queheader()). Si ya has hechoecho, verás el aviso headers already sent.
💡 Consejo: En desarrollo local con
http://localhostpuedes usar'secure' => false. Lo más cómodo es calcularlo:'secure' => !empty($_SERVER['HTTPS']). En producción, todo tu sitio debería ir por HTTPS.
Leer cookies con $_COOKIE
<?php
declare(strict_types=1);
$idioma = $_COOKIE['idioma'] ?? 'es';
// Valida SIEMPRE: el usuario puede poner cualquier valor en sus cookies
if (!in_array($idioma, ['es', 'en', 'fr'], true)) {
$idioma = 'es';
}
echo match ($idioma) {
'es' => 'Bienvenido',
'en' => 'Welcome',
'fr' => 'Bienvenue',
};
⚠️ Importante: Una cookie recién creada con
setcookie()no aparece en$_COOKIEhasta la siguiente petición, porque$_COOKIEcontiene lo que el navegador envió al pedir esta página. Si necesitas el valor ya, guárdalo también en una variable.
Borrar una cookie
No existe una función “borrar cookie”: se sobrescribe con una fecha de caducidad en el pasado, usando el mismo path y domain con que se creó:
<?php
declare(strict_types=1);
setcookie('idioma', '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
unset($_COOKIE['idioma']); // Para que tampoco esté disponible en esta petición
Ejemplo: selector de idioma y tema
<?php
// preferencias.php
declare(strict_types=1);
const IDIOMAS = ['es' => 'Español', 'en' => 'English'];
const TEMAS = ['claro', 'oscuro'];
function guardarPreferencia(string $nombre, string $valor): void
{
setcookie($nombre, $valor, [
'expires' => time() + 60 * 60 * 24 * 365,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$idioma = $_POST['idioma'] ?? '';
$tema = $_POST['tema'] ?? '';
if (array_key_exists($idioma, IDIOMAS)) {
guardarPreferencia('idioma', $idioma);
}
if (in_array($tema, TEMAS, true)) {
guardarPreferencia('tema', $tema);
}
header('Location: preferencias.php', true, 303); // PRG
exit;
}
$idioma = array_key_exists($_COOKIE['idioma'] ?? '', IDIOMAS) ? $_COOKIE['idioma'] : 'es';
$tema = in_array($_COOKIE['tema'] ?? '', TEMAS, true) ? $_COOKIE['tema'] : 'claro';
?>
<!DOCTYPE html>
<html lang="<?= $idioma ?>">
<head>
<meta charset="UTF-8">
<title>Preferencias</title>
<style>
body.oscuro { background: #1e1e1e; color: #eee; }
</style>
</head>
<body class="<?= $tema ?>">
<h1><?= $idioma === 'es' ? 'Tus preferencias' : 'Your preferences' ?></h1>
<form method="post">
<select name="idioma">
<?php foreach (IDIOMAS as $codigo => $nombre): ?>
<option value="<?= $codigo ?>" <?= $codigo === $idioma ? 'selected' : '' ?>><?= $nombre ?></option>
<?php endforeach; ?>
</select>
<select name="tema">
<?php foreach (TEMAS as $t): ?>
<option value="<?= $t ?>" <?= $t === $tema ? 'selected' : '' ?>><?= ucfirst($t) ?></option>
<?php endforeach; ?>
</select>
<button>Guardar</button>
</form>
</body>
</html>
Aquí podemos imprimir $idioma y $tema sin escapar porque solo pueden contener valores de nuestra lista blanca. Si no estuvieran validados, habría que usar htmlspecialchars().
Qué NO guardar en una cookie
⚠️ Importante: El usuario puede leer y modificar sus cookies desde las herramientas del navegador (F12 → Aplicación → Cookies). Nunca guardes en una cookie contraseñas, datos personales, el rol del usuario (
es_admin=1), precios ni nada que dé permisos. Para eso existen las sesiones.
💡 Consejo: Para las cookies más sensibles puedes usar el prefijo de nombre
__Host-(por ejemplo__Host-sesion). El navegador solo la acepta si tieneSecure,Path=/y no tieneDomain, lo que impide que un subdominio la sobrescriba.
3. Sesiones
📘 Concepto: Una sesión es un almacén de datos en el servidor asociado a un visitante concreto. PHP identifica a cada visitante con un identificador de sesión aleatorio que viaja en una cookie (por defecto llamada
PHPSESSID). Los datos se leen y escriben a través de la superglobal$_SESSION.
Uso básico
<?php
// pagina1.php
declare(strict_types=1);
session_start(); // Siempre antes de usar $_SESSION y antes de cualquier salida
$_SESSION['nombre'] = 'Ana';
$_SESSION['visitas'] = ($_SESSION['visitas'] ?? 0) + 1;
$_SESSION['preferencias'] = ['moneda' => 'EUR', 'iva' => true]; // Admite arrays
echo 'Datos guardados. <a href="pagina2.php">Ir a la página 2</a>';
<?php
// pagina2.php
declare(strict_types=1);
session_start();
echo 'Hola, ' . htmlspecialchars($_SESSION['nombre'] ?? 'desconocido') . '<br>';
echo 'Visitas: ' . ($_SESSION['visitas'] ?? 0) . '<br>';
echo 'Tu ID de sesión: ' . session_id();
// Borrar un único dato
unset($_SESSION['preferencias']);
Funciones principales
| Función | Para qué sirve |
|---|---|
session_start(array $opciones = []) | Inicia o reanuda la sesión. Lee la cookie y carga $_SESSION |
session_id() | Devuelve el identificador de la sesión actual |
session_status() | PHP_SESSION_DISABLED, PHP_SESSION_NONE o PHP_SESSION_ACTIVE |
session_regenerate_id(true) | Cambia el identificador (y borra el antiguo con true) |
session_destroy() | Borra los datos de la sesión en el servidor |
session_name() | Nombre de la cookie de sesión (PHPSESSID) |
session_get_cookie_params() | Parámetros de la cookie de sesión |
session_write_close() | Guarda y cierra la sesión antes de terminar el script (libera el bloqueo) |
💡 Consejo: Para no llamar dos veces a
session_start()(lo que genera un aviso), puedes comprobarlo así:if (session_status() === PHP_SESSION_NONE) { session_start(); }.
4. Cómo funciona una sesión por debajo
sequenceDiagram
participant N as Navegador
participant P as PHP
participant D as Disco del servidor
N->>P: GET /pagina1.php (sin cookie)
Note right of P: session_start(): no hay ID,<br/>genera uno aleatorio
P->>D: Crea sess_k3j9f2...
P-->>N: Set-Cookie: PHPSESSID=k3j9f2...; path=/; HttpOnly
N->>P: GET /pagina2.php<br/>Cookie: PHPSESSID=k3j9f2...
P->>D: Lee sess_k3j9f2...
D-->>P: nombre|s:3:"Ana";visitas|i:1;
Note right of P: Rellena $_SESSION
P->>D: Al terminar, guarda los cambios
- La primera vez,
session_start()no recibe ninguna cookie, así que genera un identificador aleatorio y envíaSet-Cookie: PHPSESSID=.... - Al terminar el script, PHP serializa
$_SESSIONy lo guarda (por defecto en un ficherosess_<id>dentro desession.save_path). - En la siguiente petición, el navegador envía la cookie;
session_start()lee el fichero y reconstruye$_SESSION.
Si abres uno de esos ficheros verás algo así:
nombre|s:3:"Ana";visitas|i:2;preferencias|a:2:{s:6:"moneda";s:3:"EUR";s:3:"iva";b:1;}
📘 Concepto: Como todo depende del identificador, quien conozca tu
PHPSESSIDpuede hacerse pasar por ti. Robarlo se llama secuestro de sesión (session hijacking), e imponerle a la víctima un identificador conocido se llama fijación de sesión (session fixation). Las medidas de las siguientes secciones existen para evitar ambos ataques.
Bloqueo de sesión
Con el almacenamiento en ficheros, PHP bloquea el fichero de sesión mientras el script se ejecuta. Si una página tarda 10 segundos, cualquier otra petición del mismo usuario espera. Si ya no vas a escribir en la sesión, libérala pronto:
<?php
session_start();
$usuario = $_SESSION['usuario'] ?? null;
session_write_close(); // A partir de aquí, otras peticiones no esperan
generarInformeLento($usuario);
Caducidad
| Directiva | Qué controla | Valor por defecto |
|---|---|---|
session.cookie_lifetime | Vida de la cookie en el navegador (0 = hasta cerrar el navegador) | 0 |
session.gc_maxlifetime | Segundos de inactividad tras los que los datos pueden borrarse en el servidor | 1440 (24 min) |
El borrado en el servidor lo hace el recolector de basura de forma probabilística, así que no es exacto. Si necesitas una caducidad precisa por inactividad, guárdala tú en la sesión:
<?php
declare(strict_types=1);
session_start();
const INACTIVIDAD_MAX = 30 * 60; // 30 minutos
if (isset($_SESSION['ultima_actividad']) && time() - $_SESSION['ultima_actividad'] > INACTIVIDAD_MAX) {
$_SESSION = [];
session_regenerate_id(true);
$_SESSION['flash'] = ['tipo' => 'aviso', 'texto' => 'Tu sesión ha caducado por inactividad'];
}
$_SESSION['ultima_actividad'] = time();
5. Regenerar y destruir la sesión
session_regenerate_id()
Cambia el identificador de sesión conservando los datos. Hay que hacerlo siempre que cambie el nivel de privilegios: al iniciar sesión, al cerrarla, al cambiar la contraseña o al convertirse en administrador.
<?php
session_start();
// ... credenciales correctas ...
session_regenerate_id(true); // true = borra el fichero de la sesión antigua
$_SESSION['usuario_id'] = 42;
⚠️ Importante: Si no regeneras el ID al hacer login, un atacante que consiguiera que la víctima usara un ID conocido (fijación de sesión) quedaría autenticado con ella en cuanto la víctima iniciara sesión.
Cerrar la sesión por completo
session_destroy() por sí solo no vacía $_SESSION en la petición actual ni borra la cookie del navegador. Un cierre completo tiene tres pasos:
<?php
declare(strict_types=1);
function cerrarSesion(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// 1. Vaciar los datos en memoria
$_SESSION = [];
// 2. Borrar la cookie de sesión del navegador (con sus mismos parámetros)
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 42000,
'path' => $p['path'],
'domain' => $p['domain'],
'secure' => $p['secure'],
'httponly' => $p['httponly'],
'samesite' => $p['samesite'],
]);
// 3. Destruir los datos en el servidor
session_destroy();
}
| Paso | Sin él… |
|---|---|
$_SESSION = [] | El resto del script aún ve los datos del usuario |
| Borrar la cookie | El navegador sigue enviando un ID (ya vacío) |
session_destroy() | Los datos siguen en el disco del servidor hasta que los recoja el recolector de basura |
6. Configuración segura de sesiones
Puedes configurar las sesiones en php.ini o, para cada aplicación, pasando opciones a session_start() (sin el prefijo session.):
; php.ini
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Lax"
session.gc_maxlifetime = 1800
<?php
// sesion.php: inclúyelo al principio de cada página
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start([
'name' => 'MITIENDA_SID', // Nombre propio (no revela que usas PHP)
'use_strict_mode' => true, // Rechaza IDs que no haya generado el servidor
'use_only_cookies'=> true, // El ID nunca viaja en la URL
'cookie_httponly' => true, // JavaScript no puede leer la cookie
'cookie_secure' => !empty($_SERVER['HTTPS']), // Solo por HTTPS (true en producción)
'cookie_samesite' => 'Lax', // No se envía en POST desde otros sitios
'gc_maxlifetime' => 1800, // 30 minutos
]);
}
| Opción | Ataque que mitiga |
|---|---|
use_strict_mode | Fijación de sesión: si alguien envía un ID inventado, PHP genera uno nuevo |
use_only_cookies | Robo del ID por URL (enlaces compartidos, cabecera Referer) |
cookie_httponly | Robo de la cookie mediante XSS (document.cookie) |
cookie_secure | Robo de la cookie en redes WiFi abiertas (tráfico sin cifrar) |
cookie_samesite | CSRF: peticiones forjadas desde otras webs |
session_regenerate_id(true) en login | Fijación de sesión |
💡 Consejo: Centraliza el arranque de sesión en un único fichero (
sesion.phpobootstrap.php) e inclúyelo conrequire_onceal principio de cada página. Así la configuración es idéntica en todo el sitio.
7. Login y logout básico
Vamos a crear un sistema de autenticación con usuarios en un array (en el módulo 15 vendrán de una base de datos).
Contraseñas: password_hash() y password_verify()
📘 Concepto: Las contraseñas nunca se guardan en texto plano ni “cifradas”: se guarda un hash, una huella irreversible. Para comprobar una contraseña, se calcula de nuevo y se compara.
password_hash()además añade una sal aleatoria (dos usuarios con la misma contraseña tienen hashes distintos) y es deliberadamente lento para dificultar los ataques de fuerza bruta.
<?php
declare(strict_types=1);
$hash = password_hash('MiClaveSegura2026', PASSWORD_DEFAULT);
echo $hash . PHP_EOL;
// $2y$12$Qm3o0b... (60 caracteres; cambia en cada ejecución)
var_dump(password_verify('MiClaveSegura2026', $hash)); // bool(true)
var_dump(password_verify('otraclave', $hash)); // bool(false)
// ¿Hay que actualizar el hash (porque cambió el algoritmo o el coste)?
var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT)); // bool(false)
| Función | Uso |
|---|---|
password_hash($clave, PASSWORD_DEFAULT) | Genera el hash (hoy bcrypt; en PHP 8.4 su coste por defecto subió a 12) |
password_hash($clave, PASSWORD_ARGON2ID) | Argon2id, si PHP está compilado con soporte |
password_verify($clave, $hash) | Comprueba una contraseña contra su hash |
password_needs_rehash($hash, $algo) | Indica si conviene recalcular el hash tras un login correcto |
⚠️ Importante: Nunca uses
md5()nisha1()(ni siquierahash('sha256', ...)) para contraseñas: son rápidos, y eso es exactamente lo que no quieres. Guarda el hash en una columna de al menos 255 caracteres, porque su longitud puede crecer en el futuro.
Estructura de ficheros
login-demo/
├── sesion.php ← Arranque seguro de la sesión + funciones auxiliares
├── usuarios.php ← "Base de datos" de usuarios
├── login.php ← Formulario y procesamiento del login
├── privado.php ← Página protegida
└── logout.php ← Cierre de sesión
usuarios.php
<?php
declare(strict_types=1);
// Los hashes se generaron con password_hash('...', PASSWORD_DEFAULT)
// ana → "ClaveDeAna2026" luis → "ClaveDeLuis2026"
return [
'ana@correo.es' => [
'id' => 1,
'nombre' => 'Ana',
'rol' => 'admin',
'hash' => password_hash('ClaveDeAna2026', PASSWORD_DEFAULT), // En la vida real: el hash ya calculado
],
'luis@correo.es' => [
'id' => 2,
'nombre' => 'Luis',
'rol' => 'usuario',
'hash' => password_hash('ClaveDeLuis2026', PASSWORD_DEFAULT),
],
];
💡 Consejo: Aquí calculamos el hash al cargar el fichero solo para que el ejemplo funcione al copiarlo. En una aplicación real el hash se calcula una vez al registrarse y se guarda; el fichero o la base de datos contendrían solo cadenas
$2y$12$....
sesion.php
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start([
'use_strict_mode' => true,
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']),
'cookie_samesite' => 'Lax',
]);
}
function e(string|int|float|null $v): string
{
return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** Devuelve el usuario autenticado o null */
function usuarioActual(): ?array
{
return $_SESSION['usuario'] ?? null;
}
/** Si no hay usuario autenticado, redirige al login y termina */
function requerirLogin(): array
{
$usuario = usuarioActual();
if ($usuario === null) {
flash('aviso', 'Debes iniciar sesión para ver esa página');
$_SESSION['volver_a'] = $_SERVER['REQUEST_URI'] ?? '/privado.php';
header('Location: login.php', true, 303);
exit;
}
return $usuario;
}
/** Exige un rol concreto */
function requerirRol(string $rol): array
{
$usuario = requerirLogin();
if ($usuario['rol'] !== $rol) {
http_response_code(403);
exit('No tienes permiso para acceder a esta página');
}
return $usuario;
}
/** Guarda un mensaje flash (ver sección 8) */
function flash(string $tipo, string $texto): void
{
$_SESSION['flash'][] = ['tipo' => $tipo, 'texto' => $texto];
}
/** Devuelve los mensajes flash y los borra */
function obtenerFlashes(): array
{
$mensajes = $_SESSION['flash'] ?? [];
unset($_SESSION['flash']);
return $mensajes;
}
/** Token CSRF de la sesión (lo estudiarás a fondo en el módulo 16) */
function tokenCsrf(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
function comprobarCsrf(): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', (string) ($_POST['csrf'] ?? ''))) {
http_response_code(400);
exit('Petición no válida');
}
}
login.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/sesion.php';
if (usuarioActual() !== null) {
header('Location: privado.php', true, 303);
exit;
}
$usuarios = require __DIR__ . '/usuarios.php';
$email = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
comprobarCsrf();
$email = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
$clave = (string) ($_POST['clave'] ?? '');
$usuario = $usuarios[$email] ?? null;
if ($usuario !== null && password_verify($clave, $usuario['hash'])) {
// 1. Nuevo ID de sesión: evita la fijación de sesión
session_regenerate_id(true);
// 2. Guardar SOLO lo necesario (nunca el hash)
$_SESSION['usuario'] = [
'id' => $usuario['id'],
'nombre' => $usuario['nombre'],
'email' => $email,
'rol' => $usuario['rol'],
];
$_SESSION['login_en'] = time();
flash('exito', "¡Bienvenida/o, {$usuario['nombre']}!");
$destino = $_SESSION['volver_a'] ?? 'privado.php';
unset($_SESSION['volver_a']);
// Solo redirigimos a rutas internas (evita redirecciones abiertas)
if (!str_starts_with($destino, '/') || str_starts_with($destino, '//')) {
$destino = 'privado.php';
}
header('Location: ' . $destino, true, 303);
exit;
}
// Mensaje genérico: no revelamos si el email existe
$error = 'Email o contraseña incorrectos';
http_response_code(401);
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Iniciar sesión</title></head>
<body>
<h1>Iniciar sesión</h1>
<?php foreach (obtenerFlashes() as $m): ?>
<p class="<?= e($m['tipo']) ?>"><?= e($m['texto']) ?></p>
<?php endforeach; ?>
<?php if ($error): ?><p style="color:red"><?= e($error) ?></p><?php endif; ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= e(tokenCsrf()) ?>">
<p><label>Email <input type="email" name="email" value="<?= e($email) ?>" autocomplete="username" required></label></p>
<p><label>Contraseña <input type="password" name="clave" autocomplete="current-password" required></label></p>
<button>Entrar</button>
</form>
</body>
</html>
privado.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/sesion.php';
$usuario = requerirLogin(); // Si no hay sesión, redirige y termina
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Zona privada</title></head>
<body>
<?php foreach (obtenerFlashes() as $m): ?>
<p class="<?= e($m['tipo']) ?>"><?= e($m['texto']) ?></p>
<?php endforeach; ?>
<h1>Hola, <?= e($usuario['nombre']) ?></h1>
<p>Has iniciado sesión como <strong><?= e($usuario['email']) ?></strong> (rol: <?= e($usuario['rol']) ?>).</p>
<p>Sesión iniciada a las <?= date('H:i', $_SESSION['login_en']) ?>.</p>
<?php if ($usuario['rol'] === 'admin'): ?>
<p><a href="admin.php">Panel de administración</a></p>
<?php endif; ?>
<!-- El logout se hace por POST: un simple enlace GET podría dispararse desde otra web -->
<form method="post" action="logout.php">
<input type="hidden" name="csrf" value="<?= e(tokenCsrf()) ?>">
<button>Cerrar sesión</button>
</form>
</body>
</html>
logout.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/sesion.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
comprobarCsrf();
// Cierre completo (sección 5)
$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', [
'expires' => time() - 42000, 'path' => $p['path'], 'domain' => $p['domain'],
'secure' => $p['secure'], 'httponly' => $p['httponly'], 'samesite' => $p['samesite'],
]);
session_destroy();
// Nueva sesión vacía solo para mostrar el mensaje de despedida
session_start();
flash('info', 'Has cerrado sesión correctamente');
header('Location: login.php', true, 303);
exit;
⚠️ Importante: Fíjate en tres detalles de seguridad del login: (1) el mensaje de error es genérico para no revelar qué emails existen; (2) la sesión guarda los datos mínimos, nunca la contraseña ni el hash; (3) la redirección posterior al login solo acepta rutas internas, para que nadie pueda usar tu login para redirigir a una web falsa (open redirect).
💡 Consejo: En una aplicación real añadirías límite de intentos (por ejemplo, bloquear tras 5 fallos durante 15 minutos) y
password_needs_rehash()tras un login correcto para actualizar hashes antiguos. Lo verás en el módulo 16.
8. Mensajes flash
📘 Concepto: Un mensaje flash es un mensaje que se guarda en la sesión para mostrarse una única vez en la siguiente página y después se borra. Es el complemento perfecto del patrón PRG: el POST guarda el mensaje, redirige, y la página GET lo muestra.
Ya los has usado en el login con las funciones flash() y obtenerFlashes(). Su ciclo de vida:
flowchart LR
A[POST /guardar.php] -->|"flash('exito', 'Guardado')"| B[(Sesión)]
A -->|303 Location| C[GET /lista.php]
B -->|obtenerFlashes| C
C -->|unset| D[Mensaje borrado]
D -->|F5 en lista.php| E[Ya no aparece]
Una versión con estilos de Bootstrap:
<?php
// parcial-flashes.php: inclúyelo donde quieras mostrar los mensajes
$clases = ['exito' => 'success', 'error' => 'danger', 'aviso' => 'warning', 'info' => 'info'];
foreach (obtenerFlashes() as $m): ?>
<div class="alert alert-<?= $clases[$m['tipo']] ?? 'secondary' ?> alert-dismissible fade show" role="alert">
<?= e($m['texto']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Cerrar"></button>
</div>
<?php endforeach; ?>
<?php
// guardar-producto.php
require_once __DIR__ . '/sesion.php';
// ... validar y guardar ...
flash('exito', 'Producto guardado correctamente');
header('Location: productos.php', true, 303);
exit;
9. Carrito de la compra en sesión
El carrito es el ejemplo clásico de sesión: el usuario añade productos en distintas páginas y el servidor los recuerda.
<?php
// carrito.php
declare(strict_types=1);
require_once __DIR__ . '/sesion.php';
// Catálogo (en el módulo 15 vendrá de la base de datos)
const CATALOGO = [
101 => ['nombre' => 'Camiseta PHP', 'precio' => 19.95],
102 => ['nombre' => 'Taza elePHPant', 'precio' => 12.50],
103 => ['nombre' => 'Pegatinas (x10)', 'precio' => 4.99],
];
// En la sesión guardamos SOLO id => cantidad. El precio se lee siempre del catálogo
$_SESSION['carrito'] ??= [];
function anadirAlCarrito(int $id, int $cantidad): void
{
if (!isset(CATALOGO[$id]) || $cantidad < 1) {
return;
}
$actual = $_SESSION['carrito'][$id] ?? 0;
$_SESSION['carrito'][$id] = min($actual + $cantidad, 99);
}
function cambiarCantidad(int $id, int $cantidad): void
{
if ($cantidad <= 0) {
unset($_SESSION['carrito'][$id]);
} elseif (isset(CATALOGO[$id])) {
$_SESSION['carrito'][$id] = min($cantidad, 99);
}
}
function vaciarCarrito(): void
{
$_SESSION['carrito'] = [];
}
/** @return array{lineas: list<array>, total: float, unidades: int} */
function resumenCarrito(): array
{
$lineas = [];
$total = 0.0;
$unidades = 0;
foreach ($_SESSION['carrito'] as $id => $cantidad) {
$producto = CATALOGO[$id];
$subtotal = $producto['precio'] * $cantidad;
$lineas[] = ['id' => $id, 'nombre' => $producto['nombre'], 'precio' => $producto['precio'],
'cantidad' => $cantidad, 'subtotal' => $subtotal];
$total += $subtotal;
$unidades += $cantidad;
}
return ['lineas' => $lineas, 'total' => round($total, 2), 'unidades' => $unidades];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
comprobarCsrf();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT) ?: 0;
$cantidad = filter_input(INPUT_POST, 'cantidad', FILTER_VALIDATE_INT) ?? 1;
match ($_POST['accion'] ?? '') {
'anadir' => anadirAlCarrito($id, $cantidad ?: 1),
'cambiar' => cambiarCantidad($id, (int) $cantidad),
'vaciar' => vaciarCarrito(),
default => null,
};
header('Location: carrito.php', true, 303);
exit;
}
$resumen = resumenCarrito();
$euros = fn(float $n): string => number_format($n, 2, ',', '.') . ' €';
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Tienda</title></head>
<body>
<h1>Productos</h1>
<?php foreach (CATALOGO as $id => $p): ?>
<form method="post" style="margin-bottom: .5em">
<input type="hidden" name="csrf" value="<?= e(tokenCsrf()) ?>">
<input type="hidden" name="accion" value="anadir">
<input type="hidden" name="id" value="<?= $id ?>">
<?= e($p['nombre']) ?> — <?= $euros($p['precio']) ?>
<input type="number" name="cantidad" value="1" min="1" max="99" style="width: 4em">
<button>Añadir</button>
</form>
<?php endforeach; ?>
<h2>Tu carrito (<?= $resumen['unidades'] ?> unidades)</h2>
<?php if (!$resumen['lineas']): ?>
<p>El carrito está vacío.</p>
<?php else: ?>
<table border="1" cellpadding="6">
<tr><th>Producto</th><th>Precio</th><th>Cantidad</th><th>Subtotal</th></tr>
<?php foreach ($resumen['lineas'] as $l): ?>
<tr>
<td><?= e($l['nombre']) ?></td>
<td><?= $euros($l['precio']) ?></td>
<td>
<form method="post">
<input type="hidden" name="csrf" value="<?= e(tokenCsrf()) ?>">
<input type="hidden" name="accion" value="cambiar">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="number" name="cantidad" value="<?= $l['cantidad'] ?>" min="0" max="99" style="width: 4em">
<button>Actualizar</button>
</form>
</td>
<td><?= $euros($l['subtotal']) ?></td>
</tr>
<?php endforeach; ?>
<tr><th colspan="3">Total</th><th><?= $euros($resumen['total']) ?></th></tr>
</table>
<form method="post">
<input type="hidden" name="csrf" value="<?= e(tokenCsrf()) ?>">
<input type="hidden" name="accion" value="vaciar">
<button>Vaciar carrito</button>
</form>
<?php endif; ?>
</body>
</html>
⚠️ Importante: Guarda en la sesión solo identificadores y cantidades, nunca el precio que llega del formulario. Si el precio viajara en un campo oculto, cualquiera podría cambiarlo a 0,01 € con las herramientas del navegador. El precio siempre se consulta en el servidor.
10. Contador de visitas: cookie frente a sesión
<?php
// visitas.php
declare(strict_types=1);
require_once __DIR__ . '/sesion.php';
// Contador por sesión: cuenta páginas vistas en esta visita
$_SESSION['paginas_vistas'] = ($_SESSION['paginas_vistas'] ?? 0) + 1;
// Contador por cookie: cuenta visitas a lo largo de un año
$visitasTotales = filter_var($_COOKIE['visitas'] ?? 0, FILTER_VALIDATE_INT, [
'options' => ['default' => 0, 'min_range' => 0],
]);
// Solo contamos una "visita" nueva cuando empieza una sesión nueva
if ($_SESSION['paginas_vistas'] === 1) {
$visitasTotales++;
setcookie('visitas', (string) $visitasTotales, [
'expires' => time() + 60 * 60 * 24 * 365,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
}
$ultima = $_COOKIE['ultima_visita'] ?? null;
setcookie('ultima_visita', date('d/m/Y H:i'), [
'expires' => time() + 60 * 60 * 24 * 365, 'path' => '/', 'httponly' => true, 'samesite' => 'Lax',
]);
?>
<p>Páginas vistas en esta sesión: <?= $_SESSION['paginas_vistas'] ?></p>
<p>Número de visitas a este sitio: <?= $visitasTotales ?></p>
<?php if ($ultima): ?>
<p>Tu última visita fue el <?= e($ultima) ?></p>
<?php else: ?>
<p>¡Es tu primera visita!</p>
<?php endif; ?>
💡 Consejo: Este contador “por cookie” es solo orientativo: el usuario puede borrarlo o falsearlo. Para estadísticas reales del sitio, el contador debe estar en el servidor (fichero o base de datos).
11. “Recordarme”: cómo funciona
La sesión dura lo que dura la cookie de sesión (normalmente hasta cerrar el navegador) o hasta que caduca por inactividad. La casilla “Recordarme” permite volver días después sin introducir la contraseña. Es una funcionalidad delicada: esa cookie equivale a una contraseña.
⚠️ Importante: Nunca implementes “recordarme” guardando el email, el ID de usuario o la contraseña (ni su hash) en una cookie. Cualquiera podría fabricarse esa cookie y entrar como otra persona.
El enfoque correcto: tokens aleatorios con selector y validador
sequenceDiagram
participant N as Navegador
participant S as Servidor
participant BD as Base de datos
N->>S: POST /login (credenciales + recordarme)
S->>S: selector = random_bytes(12)<br/>validador = random_bytes(32)
S->>BD: Guarda selector, hash(validador),<br/>usuario_id, caduca = +30 días
S-->>N: Set-Cookie: recordarme=selector:validador<br/>(HttpOnly, Secure, SameSite=Lax, 30 días)
Note over N,S: ... días después, sin sesión activa ...
N->>S: GET /privado<br/>Cookie: recordarme=selector:validador
S->>BD: Busca por selector
BD-->>S: hash guardado, usuario_id, caducidad
S->>S: hash_equals(hash guardado, hash(validador))
S->>BD: Rota: borra el token y crea uno nuevo
S-->>N: Nueva sesión + nueva cookie recordarme
Los pasos clave:
- Al hacer login con “recordarme” se generan dos valores aleatorios: un selector (para buscar el registro) y un validador (el secreto).
- En la base de datos se guarda el selector, el hash del validador (
hash('sha256', $validador)), el usuario y la fecha de caducidad. Si roban la base de datos, los validadores no se pueden usar. - En la cookie (
HttpOnly,Secure,SameSite) se guardaselector:validador. - Cuando vuelve sin sesión, se busca el registro por el selector, se compara el hash del validador con
hash_equals()(comparación en tiempo constante) y se comprueba la caducidad. - Si es válido: se inicia sesión, se llama a
session_regenerate_id(true)y se rota el token (se borra y se emite uno nuevo). - Al cerrar sesión o cambiar la contraseña, se borran los tokens del usuario.
Un esbozo del código de generación (la parte de base de datos la podrás completar tras el módulo 15):
<?php
declare(strict_types=1);
function emitirTokenRecordarme(int $usuarioId): void
{
$selector = bin2hex(random_bytes(12));
$validador = bin2hex(random_bytes(32));
$caduca = time() + 60 * 60 * 24 * 30;
// guardarTokenEnBD($selector, hash('sha256', $validador), $usuarioId, $caduca);
setcookie('recordarme', "$selector:$validador", [
'expires' => $caduca,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
💡 Consejo: Para acciones sensibles (cambiar la contraseña, el email o hacer un pago) pide de nuevo la contraseña aunque el usuario haya entrado con “recordarme”. Los frameworks como Symfony (
remember_me) ya implementan todo esto por ti.
12. Errores comunes
| Problema | Causa | Solución |
|---|---|---|
session_start(): Session cannot be started after headers have already been sent | Hay salida antes de session_start() | Llámalo en la primera línea; sin espacios ni BOM antes de <?php |
$_SESSION vacío en la segunda página | Falta session_start() en esa página | Inclúyelo en todas (mejor en un sesion.php común) |
La cookie no aparece en $_COOKIE | Se acaba de crear en esta misma petición | Estará en la siguiente; usa la variable mientras tanto |
| La cookie no se borra | path o domain distintos a los de creación | Usa exactamente los mismos parámetros |
| La cookie no se guarda en local | secure => true con http:// | Usa 'secure' => !empty($_SERVER['HTTPS']) en desarrollo |
| El usuario sigue “logueado” tras el logout | Solo se llamó a session_destroy() | Vacía $_SESSION, borra la cookie y destruye |
| Las páginas se quedan “colgadas” en paralelo | Bloqueo del fichero de sesión | session_write_close() cuando ya no escribas |
| Precio manipulado en el carrito | El precio viaja desde el cliente | Guarda solo IDs y cantidades; precio del servidor |
13. Ejercicios
Ejercicio 1: Banner de cookies
Crea un aviso de cookies que aparezca en todas las páginas hasta que el usuario pulse “Aceptar” o “Rechazar”. Guarda la decisión en una cookie consentimiento (valores aceptado/rechazado, 6 meses, HttpOnly, SameSite=Lax) mediante un formulario POST con PRG. Añade una página “Preferencias de privacidad” que permita cambiar de opinión borrando la cookie.
Ejercicio 2: Asistente de varios pasos
Crea un formulario de alta dividido en 3 pasos (datos personales, dirección, confirmación). Cada paso valida sus campos y guarda los datos en $_SESSION['alta']. Permite volver atrás sin perder lo escrito. En el paso 3 muestra el resumen y, al confirmar, guarda en altas.jsonl y borra los datos de la sesión. Impide acceder al paso 3 si los anteriores no están completos.
Ejercicio 3: Login con roles
Amplía el login de la sección 7 con tres usuarios y los roles admin, editor y lector. Crea admin.php (solo admin), editar.php (admin o editor) y leer.php (cualquier usuario autenticado). Generaliza requerirRol() para que acepte varios roles (string ...$roles). Muestra en un menú solo los enlaces a los que el usuario tiene acceso.
Ejercicio 4: Límite de intentos de login
Añade al login un contador de intentos fallidos por email guardado en un fichero JSON (no en la sesión: ¿por qué no serviría la sesión?). Tras 5 fallos, bloquea ese email durante 15 minutos mostrando cuántos minutos faltan. Un login correcto reinicia el contador.
Ejercicio 5: Caducidad por inactividad
Haz que la zona privada cierre la sesión tras 10 minutos sin actividad y tras 8 horas desde el login (aunque haya actividad). Al caducar, redirige al login con un mensaje flash que indique el motivo. Muestra en la zona privada cuántos minutos quedan de sesión.
Ejercicio 6: Carrito mejorado
Amplía el carrito con: un icono con el número de unidades visible en todas las páginas, un código de descuento (PHP85 = 10 %) guardado en la sesión, gastos de envío de 4,95 € si el total es menor que 50 € y un botón “Finalizar compra” que exija estar autenticado (redirigiendo al login y volviendo al carrito después).
Ejercicio 7: Inspector de sesión
Crea una página de depuración (solo accesible para admin) que muestre: el nombre de la sesión, el ID (solo los 6 primeros caracteres), los parámetros de la cookie (session_get_cookie_params()), el valor de session.save_path, session.gc_maxlifetime y el contenido de $_SESSION en una tabla. Añade un botón para regenerar el ID y comprueba en las herramientas del navegador que la cookie cambia.
Resumen
| Concepto | Sintaxis / clave |
|---|---|
| Crear cookie | setcookie('n', 'v', ['expires' => time()+3600, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']) |
| Leer cookie | $_COOKIE['n'] ?? 'defecto' (y validar) |
| Borrar cookie | setcookie('n', '', ['expires' => time() - 3600, 'path' => '/']) |
| Iniciar sesión | session_start([...opciones]) antes de cualquier salida |
| Guardar / leer | $_SESSION['clave'] = $valor; |
| Nuevo ID tras login | session_regenerate_id(true) |
| Cierre completo | $_SESSION = [] + borrar cookie + session_destroy() |
| Opciones seguras | use_strict_mode, cookie_httponly, cookie_secure, cookie_samesite |
| Hash de contraseña | password_hash($c, PASSWORD_DEFAULT) |
| Verificar | password_verify($c, $hash) |
| Mensaje flash | Guardar en sesión → redirigir → mostrar y unset |
| Token aleatorio | bin2hex(random_bytes(32)) |
| Comparación segura | hash_equals($esperado, $recibido) |
| Liberar bloqueo | session_write_close() |