Tema
Contraseñas y autenticación
El login es la puerta principal de cualquier aplicación. Si guardas mal las contraseñas, una sola filtración de la base de datos expone a todos tus usuarios (y como la gente reutiliza contraseñas, también sus correos y bancos). En esta lección aprenderás a guardar contraseñas como un profesional, a frenar ataques de fuerza bruta y a diseñar una recuperación de cuenta segura.
Concepto
Cifrar no es lo mismo que hashear
- Cifrar es reversible: con la clave correcta recuperas el original. Sirve para datos que necesitas leer después (un número de teléfono, un token de API de terceros).
- Hashear es de un solo sentido: conviertes la contraseña en una "huella" y no hay forma de volver atrás. Para verificar, hasheas lo que escribe el usuario y comparas huellas.
Las contraseñas siempre se hashean, nunca se cifran. Ni tú, como administrador, deberías poder verlas.
¿Por qué no MD5 o SHA-256?
MD5 y SHA-256 son hashes rápidos, diseñados para verificar archivos. Una tarjeta gráfica moderna calcula miles de millones de hashes MD5 por segundo: una contraseña de 8 caracteres cae en horas. Además, existen tablas precalculadas ("rainbow tables") con millones de contraseñas comunes.
Los algoritmos para contraseñas (bcrypt, Argon2id, scrypt) son lentos a propósito y usan una sal (salt): un valor aleatorio distinto para cada usuario, que hace que dos contraseñas iguales produzcan hashes distintos e inutiliza las rainbow tables.
| Algoritmo | ¿Para contraseñas? | Comentario |
|---|---|---|
| Texto plano | ❌ Jamás | Delito de negligencia en muchos países |
base64 | ❌ | No es ni siquiera cifrado, es codificación |
| MD5 / SHA-1 / SHA-256 | ❌ | Demasiado rápidos |
| bcrypt | ✅ | Estándar probado; máximo 72 bytes |
| Argon2id | ✅✅ | Recomendado por OWASP; resistente a GPU |
Ejemplo: registro y login en PHP
PHP trae todo lo necesario con password_hash y password_verify. Genera la sal automáticamente y la guarda dentro del propio hash.
php
<?php
require 'db.php';
$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';
if (!$email) {
exit('Correo inválido');
}
// Política razonable: longitud mínima, sin reglas absurdas de símbolos
if (mb_strlen($password) < 10) {
exit('La contraseña debe tener al menos 10 caracteres');
}
// ✅ PASSWORD_DEFAULT usa bcrypt hoy y se actualizará solo en futuras versiones
$hash = password_hash($password, PASSWORD_DEFAULT);
// O explícitamente Argon2id (si tu PHP está compilado con soporte):
// $hash = password_hash($password, PASSWORD_ARGON2ID);
try {
$stmt = $pdo->prepare('INSERT INTO usuarios (email, password_hash) VALUES (?, ?)');
$stmt->execute([$email, $hash]);
} catch (PDOException $e) {
// Código 23000 = violación de UNIQUE (correo repetido)
exit('No se pudo completar el registro');
}
echo 'Cuenta creada';php
<?php
session_start();
require 'db.php';
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$stmt = $pdo->prepare('SELECT id, password_hash FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch();
// ✅ password_verify compara en tiempo constante
if ($usuario && password_verify($password, $usuario['password_hash'])) {
// Si el algoritmo o el coste cambiaron, actualizamos el hash de forma transparente
if (password_needs_rehash($usuario['password_hash'], PASSWORD_DEFAULT)) {
$nuevo = password_hash($password, PASSWORD_DEFAULT);
$pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
->execute([$nuevo, $usuario['id']]);
}
session_regenerate_id(true); // evita fijación de sesión (lección 06)
$_SESSION['usuario_id'] = $usuario['id'];
header('Location: /panel.php');
exit;
}
// ✅ Mensaje genérico: no revelamos si el correo existe
$error = 'Correo o contraseña incorrectos';sql
CREATE TABLE usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
-- 255 caracteres: los hashes pueden crecer si cambia el algoritmo
password_hash VARCHAR(255) NOT NULL,
creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Un hash bcrypt se ve así: $2y$12$Qz1.... El 2y indica el algoritmo, 12 el coste (2^12 iteraciones) y el resto es la sal más el hash.
Ejemplo: registro y login en Node
bash
npm install bcrypt
# o, para Argon2id:
npm install argon2js
import bcrypt from 'bcrypt';
const COSTE = 12; // ~250 ms por hash en un servidor típico
app.post('/registro', async (req, res) => {
const { email, password } = req.body;
if (typeof password !== 'string' || password.length < 10) {
return res.status(400).json({ error: 'Mínimo 10 caracteres' });
}
const hash = await bcrypt.hash(password, COSTE); // genera la sal automáticamente
await pool.execute('INSERT INTO usuarios (email, password_hash) VALUES (?, ?)', [email, hash]);
res.status(201).json({ ok: true });
});
app.post('/login', async (req, res) => {
const { email = '', password = '' } = req.body;
const [filas] = await pool.execute('SELECT id, password_hash FROM usuarios WHERE email = ?', [email]);
const usuario = filas[0];
// Si el usuario no existe, comparamos contra un hash falso para tardar lo mismo
const hash = usuario?.password_hash ?? '$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x7f1rM3zZ5VJrYzE2Wc1n8aTqvKqxW';
const ok = await bcrypt.compare(password, hash);
if (!usuario || !ok) {
return res.status(401).json({ error: 'Correo o contraseña incorrectos' });
}
req.session.regenerate(err => {
if (err) return res.status(500).end();
req.session.usuarioId = usuario.id;
res.json({ ok: true });
});
});js
import argon2 from 'argon2';
// Argon2id con parámetros por defecto (recomendados por OWASP)
const hash = await argon2.hash(password, { type: argon2.argon2id });
// Verificar: el orden es (hash, contraseña)
const ok = await argon2.verify(hash, password);Error común
Crear tu propio esquema tipo sha256($password . 'mi_sal_secreta'). Sigue siendo rápido, la sal es la misma para todos y si alguien ve tu código, se acabó. Usa password_hash, bcrypt o argon2.
Frenar la fuerza bruta
Aunque los hashes sean lentos, un atacante puede probar miles de contraseñas comunes contra tu formulario de login. Necesitas limitar los intentos.
php
<?php
// Tabla: intentos_login (id, email, ip, creado_en)
function demasiadosIntentos(PDO $pdo, string $email, string $ip): bool
{
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM intentos_login
WHERE (email = ? OR ip = ?) AND creado_en > (NOW() - INTERVAL 15 MINUTE)'
);
$stmt->execute([$email, $ip]);
return $stmt->fetchColumn() >= 5; // máximo 5 intentos cada 15 minutos
}
function registrarIntento(PDO $pdo, string $email, string $ip): void
{
$pdo->prepare('INSERT INTO intentos_login (email, ip) VALUES (?, ?)')->execute([$email, $ip]);
}
$ip = $_SERVER['REMOTE_ADDR'];
if (demasiadosIntentos($pdo, $email, $ip)) {
http_response_code(429);
exit('Demasiados intentos. Espera 15 minutos.');
}
// ...si el login falla:
registrarIntento($pdo, $email, $ip);
// ...si el login es correcto, puedes borrar los intentos de ese correojs
import rateLimit from 'express-rate-limit';
const limiteLogin = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
limit: 5, // 5 intentos por IP en la ventana
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { error: 'Demasiados intentos. Espera 15 minutos.' },
});
app.post('/login', limiteLogin, loginHandler);Detrás de un proxy
Si tu app está detrás de Cloudflare, Nginx o un balanceador, REMOTE_ADDR será la IP del proxy. En Express configura app.set('trust proxy', 1); en PHP confía en X-Forwarded-For solo si viene de tu proxy conocido.
Recuperación de contraseña segura
Es un punto débil frecuente. El flujo correcto:
- El usuario escribe su correo. Respondes siempre "Si el correo existe, te enviamos un enlace" (no revelas si existe).
- Generas un token aleatorio largo, guardas su hash con fecha de expiración (por ejemplo, 1 hora).
- Envías por correo el enlace con el token original.
- Al recibirlo, hasheas el token, lo buscas, verificas que no haya expirado, cambias la contraseña y borras el token (un solo uso).
- Cierras las demás sesiones del usuario.
php
<?php
// Paso 2: generar token
$token = bin2hex(random_bytes(32)); // lo que va en el enlace
$tokenHash = hash('sha256', $token); // lo que guardas (si roban la BD, no sirve)
$pdo->prepare('INSERT INTO resets (usuario_id, token_hash, expira) VALUES (?, ?, NOW() + INTERVAL 1 HOUR)')
->execute([$usuario['id'], $tokenHash]);
$enlace = 'https://tusitio.com/restablecer.php?token=' . $token;
// enviarCorreo($email, $enlace);
// Paso 4: validar
$stmt = $pdo->prepare('SELECT usuario_id FROM resets WHERE token_hash = ? AND expira > NOW()');
$stmt->execute([hash('sha256', $_POST['token'] ?? '')]);
$reset = $stmt->fetch();
if (!$reset) {
exit('Enlace inválido o expirado');
}
$pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
->execute([password_hash($_POST['password'], PASSWORD_DEFAULT), $reset['usuario_id']]);
$pdo->prepare('DELETE FROM resets WHERE usuario_id = ?')->execute([$reset['usuario_id']]);¿Por qué SHA-256 aquí sí vale?
El token es aleatorio de 256 bits, no una contraseña elegida por una persona. No hay diccionario que probar, así que un hash rápido es suficiente.
Buenas prácticas de autenticación
- Longitud > complejidad: exige mínimo 10-12 caracteres y permite frases largas. Las reglas tipo "una mayúscula, un símbolo" producen
Password1!. - Contraseñas filtradas: puedes rechazar contraseñas que aparecen en filtraciones con la API gratuita de Have I Been Pwned (k-anonymity: solo envías los 5 primeros caracteres del SHA-1).
- Autenticación en dos pasos (2FA): con TOTP (Google Authenticator) usando librerías como
pragmarx/google2fa(PHP) uotplib(Node). Ofrécelo al menos a los administradores. - Mensajes genéricos en login, registro y recuperación.
- Notifica por correo los cambios de contraseña o correo.
- Nunca envíes contraseñas por correo, ni siquiera al registrarse.
Ejercicios
- Tienes una base de datos heredada con contraseñas en MD5. ¿Cómo migras a
password_hashsin obligar a todos a cambiar su contraseña? - Escribe en Node una función
cambiarPassword(usuarioId, actual, nueva)que verifique la contraseña actual antes de cambiarla. - ¿Por qué el mensaje "Este correo no está registrado" es un problema de seguridad?
Ver solución
- Migración progresiva: al hacer login, si el hash guardado tiene 32 caracteres (MD5), compruebas con
md5(); si coincide, generas el nuevo hash y lo guardas. Mejor aún, envuelve los MD5 existentes de inmediato:password_hash($md5Viejo)y verifica conpassword_verify(md5($password), $hash)hasta el siguiente login, para no dejar MD5 desnudos en la BD.
php
<?php
if (strlen($u['password_hash']) === 32) { // formato MD5 heredado
if (hash_equals($u['password_hash'], md5($password))) {
$nuevo = password_hash($password, PASSWORD_DEFAULT);
$pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')->execute([$nuevo, $u['id']]);
$loginOk = true;
}
} else {
$loginOk = password_verify($password, $u['password_hash']);
}- Cambio de contraseña:
js
async function cambiarPassword(usuarioId, actual, nueva) {
const [filas] = await pool.execute('SELECT password_hash FROM usuarios WHERE id = ?', [usuarioId]);
if (!filas[0] || !(await bcrypt.compare(actual, filas[0].password_hash))) {
throw new Error('La contraseña actual no es correcta');
}
if (nueva.length < 10) throw new Error('Mínimo 10 caracteres');
const hash = await bcrypt.hash(nueva, 12);
await pool.execute('UPDATE usuarios SET password_hash = ? WHERE id = ?', [hash, usuarioId]);
}- Permite la enumeración de usuarios: un atacante descubre qué correos tienen cuenta y luego los ataca con fuerza bruta o phishing dirigido.
Resumen
- Las contraseñas se hashean, nunca se cifran ni se guardan en texto plano.
- Usa
password_hash/password_verifyen PHP y bcrypt o argon2 en Node. - MD5, SHA-1 y SHA-256 no sirven para contraseñas.
- Limita los intentos de login y responde con mensajes genéricos.
- La recuperación usa tokens aleatorios, de un solo uso, con expiración y guardados como hash.
- Ofrece 2FA, sobre todo a administradores.