password_hash() y compruébalas con
password_verify().
Tras un login correcto, llama a session_regenerate_id(true), guarda solo el id del usuario
en
la sesión y protege las páginas privadas con una función que lo compruebe.
El login es casi siempre lo primero que se ataca de una aplicación, y también donde más errores se copian de tutoriales antiguos: contraseñas en MD5, sesiones que nunca se regeneran, mensajes que revelan qué emails existen. En el libro Programación Full-Stack el sistema de login es un capítulo completo y la base del proyecto de gestión de usuarios. Este artículo resume el enfoque.
La tabla de usuarios
CREATE TABLE usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(80) NOT NULL,
email VARCHAR(190) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
La columna del hash es VARCHAR(255) a propósito: el algoritmo por defecto puede cambiar en
futuras versiones de PHP y producir hashes más largos. El email es UNIQUE para que la base
de datos impida duplicados aunque tu código falle.
Registro: nunca guardes la contraseña
Una contraseña no se guarda ni cifrada ni “encriptada con MD5”: se guarda su hash, una
transformación de un solo sentido. password_hash() usa un algoritmo lento diseñado para
esto (bcrypt por defecto) y añade una sal aleatoria automáticamente.
<?php
require_once __DIR__ . '/config/db.php';
$nombre = trim($_POST['nombre'] ?? '');
$email = trim($_POST['email'] ?? '');
$pass = $_POST['password'] ?? '';
$errores = [];
if ($nombre === '') $errores[] = 'El nombre es obligatorio.';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errores[] = 'Email no válido.';
if (mb_strlen($pass) < 10) $errores[] = 'La contraseña debe tener al menos 10 caracteres.';
if (!$errores) {
$hash = password_hash($pass, PASSWORD_DEFAULT);
try {
$stmt = db()->prepare(
'INSERT INTO usuarios (nombre, email, password_hash) VALUES (?, ?, ?)'
);
$stmt->execute([$nombre, $email, $hash]);
header('Location: login.php?registrado=1');
exit;
} catch (PDOException $e) {
if ((int) ($e->errorInfo[1] ?? 0) === 1062) { // clave duplicada
$errores[] = 'Ya existe una cuenta con ese email.';
} else {
throw $e;
}
}
}
md5() y sha1() no sirven para contraseñas: son tan rápidos que un ordenador
normal prueba millones de combinaciones por segundo. Si heredas un sistema con MD5, migra a
password_hash la próxima vez que cada usuario inicie sesión.
Login: verificar y abrir la sesión
<?php
require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/config/sesion.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = db()->prepare('SELECT id, nombre, password_hash FROM usuarios WHERE email = ?');
$stmt->execute([trim($_POST['email'] ?? '')]);
$usuario = $stmt->fetch();
if ($usuario && password_verify($_POST['password'] ?? '', $usuario['password_hash'])) {
// 1. Nuevo id de sesión: evita la fijación de sesión
session_regenerate_id(true);
// 2. Guarda lo mínimo imprescindible
$_SESSION['usuario_id'] = $usuario['id'];
$_SESSION['nombre'] = $usuario['nombre'];
// 3. Si el algoritmo por defecto cambió, actualiza el hash
if (password_needs_rehash($usuario['password_hash'], PASSWORD_DEFAULT)) {
$nuevo = password_hash($_POST['password'], PASSWORD_DEFAULT);
db()->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
->execute([$nuevo, $usuario['id']]);
}
header('Location: panel.php');
exit;
}
// El mismo mensaje para email inexistente y contraseña incorrecta
$error = 'Email o contraseña incorrectos.';
}
El mensaje de error genérico no es pereza: si dijeras “ese email no existe”, cualquiera podría averiguar quién tiene cuenta en tu sitio.
Configura la cookie de sesión
<?php
session_set_cookie_params([
'lifetime' => 0, // hasta cerrar el navegador
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']), // solo por HTTPS en producción
'httponly' => true, // JavaScript no puede leerla
'samesite' => 'Lax', // mitiga ataques CSRF
]);
session_start();
Proteger páginas privadas
<?php
require_once __DIR__ . '/sesion.php';
function requiereLogin(): void
{
if (empty($_SESSION['usuario_id'])) {
header('Location: login.php');
exit;
}
}
<?php
require_once __DIR__ . '/config/auth.php';
requiereLogin(); // primera línea de cada página privada
?>
<h1>Hola, <?= htmlspecialchars($_SESSION['nombre']) ?></h1>
<a href="logout.php">Cerrar sesión</a>
Cerrar sesión correctamente
No basta con un unset: hay que vaciar los datos, borrar la cookie y destruir la sesión en el
servidor.
<?php
require_once __DIR__ . '/config/sesion.php';
$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
session_destroy();
header('Location: login.php');
exit;
Checklist de seguridad
- Contraseñas con
password_hash; nunca en texto plano, MD5 ni SHA1. - Consultas preparadas en registro y login (ver prevenir la inyección SQL).
session_regenerate_id(true)después de cada login correcto.- Cookie de sesión con
httponly,secureysamesite. - Mensajes de error que no revelen si el email existe.
- Todo el sitio servido por HTTPS.
- Limitar los intentos de login por IP o por cuenta para frenar ataques de fuerza bruta.
Construye un sistema de usuarios completo
El libro Programación Full-Stack dedica un capítulo al login y la autenticación, y un proyecto entero al sistema de gestión de usuarios.
Comprar en Amazon