Un inicio de sesión seguro en PHP, paso a paso

Login seguro en PHP: el formulario de inicio de sesión es la puerta de la aplicación y el lugar que más se ataca. Este ejemplo reúne en un archivo las medidas que no pueden faltar y comprueba con curl que cada una funciona.

Las contraseñas: password_hash

Primero, dar de alta un usuario. La contraseña nunca se guarda: se guarda su resumen con password_hash, que es lento a propósito y lleva una sal aleatoria.

<?php
declare(strict_types=1);

// Da de alta un usuario de prueba (se ejecuta una vez, desde la terminal).
$pdo = new PDO('sqlite:' . sys_get_temp_dir() . '/login.sqlite');
$pdo->exec('DROP TABLE IF EXISTS usuarios');
$pdo->exec('DROP TABLE IF EXISTS fallos');
$pdo->exec('CREATE TABLE usuarios (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, hash TEXT NOT NULL)');
$pdo->exec('CREATE TABLE fallos (email TEXT NOT NULL, momento INTEGER NOT NULL)');

$hash = password_hash('caballo-correcto-bateria-grapa', PASSWORD_DEFAULT);
$pdo->prepare('INSERT INTO usuarios (email, hash) VALUES (?, ?)')->execute(['ana@ejemplo.com', $hash]);
echo "Usuario creado. El hash empieza por ", substr($hash, 0, 7), " y mide ", strlen($hash), " caracteres\n";
php alta.phpUsuario creado. El hash empieza por $2y$12$ y mide 60 caracteres

$2y$ indica bcrypt, el algoritmo por omisión, y 12 el coste. La columna de la base de datos debe admitir al menos 255 caracteres, por si PHP cambia de algoritmo en el futuro.

El código del inicio de sesión

<?php
declare(strict_types=1);

// Inicio de sesión con contraseñas en password_hash, sesión segura y límite de intentos fallidos.
session_start([
    'cookie_httponly' => true,
    'cookie_secure' => true,
    'cookie_samesite' => 'Lax',
    'use_strict_mode' => true,
]);

const MAX_INTENTOS = 5;
const VENTANA_MINUTOS = 15;

$pdo = new PDO('sqlite:' . sys_get_temp_dir() . '/login.sqlite', options: [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, hash TEXT NOT NULL)');
$pdo->exec('CREATE TABLE IF NOT EXISTS fallos (email TEXT NOT NULL, momento INTEGER NOT NULL)');

function campo(string $nombre): string
{
    $v = $_POST[$nombre] ?? '';
    return is_string($v) ? trim($v) : '';
}

function demasiadosFallos(PDO $pdo, string $email): bool
{
    $c = $pdo->prepare('SELECT COUNT(*) FROM fallos WHERE email = ? AND momento > ?');
    $c->execute([$email, time() - VENTANA_MINUTOS * 60]);
    return (int) $c->fetchColumn() >= MAX_INTENTOS;
}

function autenticar(PDO $pdo, string $email, #[\SensitiveParameter] string $clave): ?int
{
    $c = $pdo->prepare('SELECT id, hash FROM usuarios WHERE email = ?');
    $c->execute([$email]);
    $u = $c->fetch();
    // Si el correo no existe, se verifica igualmente contra un hash cualquiera: así tarda lo mismo
    // y nadie puede averiguar qué correos tienen cuenta midiendo el tiempo de respuesta.
    $hash = $u['hash'] ?? '$2y$12$iPTdsACQR08K6MgbUV.diumy9olag7/saH.72IK0svBCuh8BkaQQm';
    if (!password_verify($clave, $hash) || $u === false) {
        return null;
    }
    if (password_needs_rehash($u['hash'], PASSWORD_DEFAULT)) {
        $pdo->prepare('UPDATE usuarios SET hash = ? WHERE id = ?')->execute([password_hash($clave, PASSWORD_DEFAULT), $u['id']]);
    }
    return (int) $u['id'];
}

$ruta = (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($ruta === '/entrar' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = mb_strtolower(campo('email'));
    if (demasiadosFallos($pdo, $email)) {
        http_response_code(429);
        exit("Demasiados intentos. Espera unos minutos.\n");
    }
    $id = autenticar($pdo, $email, campo('clave'));
    if ($id === null) {
        $pdo->prepare('INSERT INTO fallos VALUES (?, ?)')->execute([$email, time()]);
        http_response_code(401);
        exit("Correo o contraseña incorrectos.\n");
    }
    $pdo->prepare('DELETE FROM fallos WHERE email = ?')->execute([$email]);
    session_regenerate_id(true);
    $_SESSION['usuario'] = $id;
    header('Location: /perfil', true, 303);
    exit;
}

if ($ruta === '/perfil') {
    if (!isset($_SESSION['usuario'])) {
        header('Location: /entrar', true, 303);
        exit;
    }
    exit("Hola, usuario {$_SESSION['usuario']}.\n");
}

if ($ruta === '/salir' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    $_SESSION = [];
    session_destroy();
    setcookie(session_name(), '', ['expires' => 1, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
    exit("Sesión cerrada.\n");
}

http_response_code(404);
exit("No existe.\n");

Cada pieza responde a un riesgo concreto:

MedidaQué evita
password_hash y password_verifyque una filtración de la base de datos revele las contraseñas
Un único mensaje, «Correo o contraseña incorrectos»que alguien averigüe qué correos tienen cuenta
Verificar contra un hash aunque el correo no existalo mismo, midiendo cuánto tarda la respuesta
Máximo 5 fallos cada 15 minutos por correoprobar contraseñas sin límite (fuerza bruta)
session_regenerate_id(true) al entrarque sirva un identificador de sesión conocido de antes (fijación de sesión)
Cookie HttpOnly, Secure y SameSiteque un script la lea, que viaje sin cifrar o que la envíen otras webs
#[\SensitiveParameter]que la contraseña aparezca en las trazas de error
Cerrar sesión solo por POSTque un enlace o una imagen de otra web cierre tu sesión

Probarlo con curl

Sin sesión, el perfil lleva a la página de entrada:

GET /perfil sin sesiónHTTP/1.1 303 See Other
Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax
Location: /entrar
Content-type: text/html; charset=UTF-8

Una contraseña equivocada y un correo que no existe reciben exactamente la misma respuesta:

POST /entrar (contraseña equivocada)HTTP/1.1 401 Unauthorized
Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax
Content-type: text/html; charset=UTF-8

Correo o contraseña incorrectos.
POST /entrar (correo sin cuenta)HTTP/1.1 401 Unauthorized
Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax
Content-type: text/html; charset=UTF-8

Correo o contraseña incorrectos.

Tras cinco fallos con el mismo correo, ni siquiera la contraseña correcta funciona durante un rato:

POST /entrar (sexto intento, con la contraseña correcta)HTTP/1.1 429 Too Many Requests
Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax
Content-type: text/html; charset=UTF-8

Demasiados intentos. Espera unos minutos.

Pasado el bloqueo (en la prueba borramos los fallos registrados, para no esperar quince minutos), la contraseña correcta (con el correo escrito con mayúsculas, que se normaliza) da una cookie de sesión nueva y redirige al perfil:

POST /entrar (contraseña correcta)HTTP/1.1 303 See Other
Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax
Location: /perfil
Content-type: text/html; charset=UTF-8
GET /perfil con la cookieHola, usuario 1.

Y al cerrar la sesión, la misma cookie ya no sirve:

POST /salirSesión cerrada.
GET /perfil con la cookie antiguaHTTP/1.1 303 See Other
Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax
Location: /entrar
Content-type: text/html; charset=UTF-8
Lo que añadiría una aplicación real. Un token CSRF en el formulario de entrada y en el de salida (lo explicamos en la lección 7), un límite también por dirección IP, para frenar a quien prueba la misma contraseña con muchos correos, y un segundo factor de autenticación para las cuentas importantes. Para la lista completa, los fallos de seguridad típicos de PHP.

Resumen

ParaUsa
Guardar la contraseñapassword_hash($clave, PASSWORD_DEFAULT)
Comprobarlapassword_verify($clave, $hash)
Actualizar el resumen con los añospassword_needs_rehash
Entrarsession_regenerate_id(true) y guardar solo el id
Frenar la fuerza brutacontar fallos por correo (y por IP) en una ventana de tiempo

Todo el código de esta página se ejecutó con PHP 8.5.12 en un contenedor limpio: la aplicación, con el servidor integrado de PHP (php -S) y sin acceso a la red, y las peticiones, con curl desde el mismo contenedor. Las respuestas están copiadas de esa ejecución.