Autenticación y Sesiones Rotas
El Problema de la Identidad
La autenticación es el proceso de verificar que un usuario es quien dice ser. La gestión de sesiones es lo que mantiene esa identidad a lo largo de las peticiones. Cuando cualquiera de las dos falla, un atacante puede hacerse pasar por otro usuario, y todo el resto de los controles de seguridad se vuelven irrelevantes. El OWASP Top 10 agrupa estos fallos bajo A07:2021 — Identification and Authentication Failures (anteriormente "Broken Authentication").
Estos fallos son especialmente peligrosos porque atacan directamente la frontera de confianza de la aplicación. No importa cuán bien protegidos estén tus datos si un atacante puede simplemente iniciar sesión como un usuario legítimo, o peor, como un administrador. Por eso la autenticación y la gestión de sesiones merecen un diseño cuidadoso y una revisión constante. Esta lección adopta un enfoque defensivo: para cada debilidad mostramos primero un ejemplo mínimo de código vulnerable y a continuación su versión corregida, para que el foco esté siempre en el arreglo.
Fuerza Bruta y Credential Stuffing
El ataque más directo contra la autenticación es la fuerza bruta: probar muchas combinaciones de usuario y contraseña hasta acertar. Una variante más eficiente es el ataque de diccionario, que prueba contraseñas comunes y conocidas en lugar de todas las combinaciones posibles. Si la aplicación no limita los intentos, un atacante automatizado puede probar millones de contraseñas contra un único endpoint de login sin que nadie lo note.
El credential stuffing es hoy una de las amenazas más prevalentes, y conviene entender por qué. No se trata de adivinar contraseñas al azar, sino de reutilizar credenciales ya válidas. El mecanismo se apoya en dos hechos: primero, las personas reutilizan la misma contraseña en múltiples servicios; segundo, existen enormes listas de credenciales expuestas en brechas de datos. A lo largo de los años, brechas de gran escala han filtrado cientos de millones de combinaciones de correo y contraseña que circulan libremente y se agregan en colecciones consolidadas. El atacante toma una de esas listas y la prueba masivamente y de forma automatizada contra tu aplicación. Como cada credencial es válida en algún sitio, basta con que una fracción de los usuarios haya reutilizado su contraseña para que un porcentaje de los intentos tenga éxito. Por eso el credential stuffing tiene tasas de acierto que la fuerza bruta pura nunca alcanza, y por eso una contraseña "fuerte" no protege si el usuario la repitió en un servicio que ya fue comprometido.
El impacto real va más allá del acceso a una cuenta: tomas de cuentas masivas, fraude, robo de datos personales, y un daño reputacional considerable cuando los usuarios descubren que su cuenta fue accedida. La defensa no puede ser una sola medida, sino una combinación: rate limiting, bloqueo temporal tras varios fallos, detección de credenciales ya comprometidas y MFA. Las vemos en detalle a continuación.
Almacenamiento Seguro de Contraseñas
La causa raíz de muchas brechas no es cómo se roban las contraseñas, sino cómo estaban almacenadas cuando fueron robadas. Guardar contraseñas en texto plano, o hashearlas con funciones rápidas como MD5 o SHA-1, significa que una filtración de la base de datos entrega las contraseñas casi inmediatamente: MD5 y SHA-1 son tan veloces que un atacante puede calcular miles de millones de hashes por segundo con hardware común y revertir la mayoría con tablas precalculadas (rainbow tables).
Vulnerable — hash rápido y sin salt, trivial de revertir:
import crypto from 'node:crypto';
function hashPassword(password) {
// MD5 es rapidísimo de forzar y no lleva salt: NO usar
return crypto.createHash('md5').update(password).digest('hex');
}
Seguro — función de hash lenta y con salt por diseño (bcrypt):
import bcrypt from 'bcrypt';
const COST_FACTOR = 12; // trabajo deliberadamente costoso
async function hashPassword(password) {
// bcrypt genera un salt único e incluido en el propio hash resultante
return bcrypt.hash(password, COST_FACTOR);
}
async function verifyPassword(password, storedHash) {
// comparación en tiempo constante frente a ataques de temporización
return bcrypt.compare(password, storedHash);
}
Las contraseñas nunca deben almacenarse en texto plano ni con cifrado reversible. Deben guardarse usando una función de hash diseñada para contraseñas —bcrypt, scrypt o Argon2 (hoy el preferido, ganador del Password Hashing Competition)— que son lentas a propósito y resistentes a ataques con hardware especializado (GPU y ASIC). Cada contraseña lleva un salt único que impide el uso de tablas precalculadas; en bcrypt y Argon2 el salt se genera y se incrusta automáticamente en el hash resultante. Ajusta el factor de coste (o los parámetros de memoria y tiempo en Argon2) tan alto como tu hardware tolere sin degradar la experiencia de login.
Rate Limiting y Bloqueo de Cuentas
Un login sin límite de intentos es una invitación abierta a la fuerza bruta y al credential stuffing. La defensa base es limitar cuántos intentos se aceptan por identidad y por origen en una ventana de tiempo.
Vulnerable — sin ningún límite de intentos:
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await users.findByEmail(email);
if (user && await verifyPassword(password, user.hash)) {
return res.json({ ok: true });
}
return res.status(401).json({ ok: false });
});
Seguro — rate limiting sobre el endpoint de login:
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // ventana de 15 minutos
max: 5, // máx. 5 intentos por IP en la ventana
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, error: 'Demasiados intentos, prueba más tarde' },
});
app.post('/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await users.findByEmail(email);
if (user && await verifyPassword(password, user.hash)) {
return res.json({ ok: true });
}
return res.status(401).json({ ok: false });
});
Combina el rate limiting por IP con un bloqueo temporal por cuenta: tras varios fallos consecutivos sobre el mismo usuario, introduce un retraso creciente (backoff exponencial) o un bloqueo de algunos minutos, y notifica al titular. Cuidado con dos matices: los atacantes distribuyen los intentos entre miles de IPs para evadir límites por origen (por eso el límite por cuenta importa), y un bloqueo mal diseñado puede convertirse en una denegación de servicio contra usuarios legítimos. Ante comportamiento sospechoso, un CAPTCHA como paso intermedio ayuda a frenar la automatización sin castigar al usuario normal.
Detección de Credenciales Comprometidas
Como el credential stuffing usa contraseñas ya filtradas, una defensa muy efectiva es rechazar contraseñas conocidas como comprometidas en el registro y en el cambio de contraseña. Servicios como Have I Been Pwned exponen esta comprobación de forma que preserva la privacidad mediante k-anonymity: tu aplicación calcula el hash SHA-1 de la contraseña, envía solo los primeros cinco caracteres del hash al servicio, recibe todos los sufijos que empiezan con ese prefijo, y compara localmente si el sufijo de tu contraseña está en la lista. Así el servicio nunca ve la contraseña ni su hash completo, y tu aplicación se entera de si esa contraseña aparece en brechas conocidas sin exponerla. Rechazar esas contraseñas rompe el modelo económico del credential stuffing en tu superficie de ataque.
Manejo Seguro de Sesiones
Una vez que el usuario se autentica, la aplicación emite un identificador de sesión. Si ese identificador se ve comprometido, el atacante secuestra la sesión sin necesidad de la contraseña. Hay varios errores clásicos que evitar. La fijación de sesión ocurre cuando la aplicación no genera un nuevo identificador tras el login, permitiendo a un atacante fijar un valor conocido antes de que la víctima inicie sesión y, luego, reutilizar ese mismo identificador ya autenticado.
Vulnerable — se conserva el id de sesión previo al login:
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) return res.status(401).json({ ok: false });
// el id de sesión anónimo se reutiliza tras autenticar: fijación posible
req.session.userId = user.id;
res.json({ ok: true });
});
Seguro — se regenera la sesión tras autenticar:
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) return res.status(401).json({ ok: false });
// nuevo id de sesión: invalida cualquier valor fijado por el atacante
req.session.regenerate((err) => {
if (err) return res.status(500).json({ ok: false });
req.session.userId = user.id;
res.json({ ok: true });
});
});
Para un manejo seguro: genera identificadores de sesión largos y aleatorios con un generador criptográficamente seguro, regenera el identificador después de cada autenticación, transmítelo siempre sobre HTTPS, y configura las cookies con los atributos HttpOnly, Secure y SameSite. Implementa expiración de sesión por inactividad y un cierre de sesión que invalide realmente la sesión en el servidor, no solo borre la cookie en el navegador. Estos detalles, descritos en la lección 1, son la diferencia entre una sesión robusta y una trivialmente secuestrable.
Autenticación Multifactor (MFA)
La medida más efectiva para reducir el impacto de credenciales robadas es la autenticación multifactor (MFA). Al exigir un segundo factor —una aplicación de códigos TOTP, una llave de seguridad FIDO2/WebAuthn o similar— además de la contraseña, incluso una contraseña comprometida resulta insuficiente para entrar. La MFA neutraliza por sí sola la mayoría de los ataques de credential stuffing y phishing de contraseñas: aunque el atacante tenga la credencial correcta de una brecha, no posee el segundo factor.
Los factores no son equivalentes en robustez. Los códigos TOTP de una app autenticadora son un gran avance sobre solo contraseña, pero siguen siendo susceptibles a phishing en tiempo real. Las llaves FIDO2/WebAuthn resisten el phishing porque el factor está ligado criptográficamente al dominio legítimo. Los códigos por SMS son el factor más débil (susceptibles a SIM swapping) y solo deberían usarse como último recurso. Como mínimo, la MFA debe ser obligatoria para todas las cuentas administrativas, donde el impacto de una toma de cuenta es máximo.
Diseñando una Autenticación Robusta
Construir autenticación segura desde cero es difícil y propenso a errores, por lo que la recomendación general es apoyarse en soluciones probadas: frameworks de autenticación maduros, proveedores de identidad y estándares como OAuth 2.0 y OpenID Connect, en lugar de inventar tu propio esquema. Estos sistemas ya resuelven correctamente la gestión de tokens, la expiración, la rotación y buena parte de los casos borde que un desarrollo propio suele pasar por alto.
Complementa el diseño con buenas políticas de contraseñas basadas en evidencia. Las guías modernas (como las de NIST) recomiendan exigir contraseñas largas —favoreciendo frases de contraseña— en lugar de reglas de complejidad arbitrarias que empujan a los usuarios a patrones predecibles. Compara las contraseñas nuevas contra listas de contraseñas filtradas, ofrece recuperación de cuenta segura que no revele si un usuario existe (mensajes de error genéricos, tiempos de respuesta uniformes), y registra y monitorea los intentos de login fallidos para detectar ataques en curso.
Para validar tus defensas, usa herramientas como Burp Suite para analizar el flujo de login, la gestión de tokens y el ciclo de vida de las sesiones (por ejemplo, verificar que el id realmente se regenera tras el login y se invalida en el logout). Como referencia normativa apóyate en la guía de autenticación de OWASP: el Authentication Cheat Sheet, el Session Management Cheat Sheet y el estándar ASVS, que ofrecen requisitos verificables punto por punto. La autenticación es la puerta de entrada: vale la pena protegerla con rigor.
Checklist de Prevención
- [ ] Hashear contraseñas con Argon2, bcrypt o scrypt; nunca MD5, SHA-1 ni texto plano.
- [ ] Usar un salt único por contraseña (bcrypt y Argon2 lo incluyen automáticamente) y un factor de coste alto.
- [ ] Aplicar rate limiting por IP en el endpoint de login y en la recuperación de cuenta.
- [ ] Añadir bloqueo temporal o backoff creciente por cuenta tras fallos repetidos, evitando crear un DoS.
- [ ] Rechazar contraseñas conocidas como comprometidas (comprobación por k-anonymity al estilo HIBP).
- [ ] Regenerar el identificador de sesión tras autenticar para prevenir la fijación de sesión.
- [ ] Emitir IDs de sesión largos y aleatorios con un generador criptográficamente seguro.
- [ ] Configurar cookies con
HttpOnly,SecureySameSite, servir siempre sobre HTTPS. - [ ] Implementar expiración por inactividad y logout que invalide la sesión en el servidor.
- [ ] Exigir MFA (TOTP o, preferiblemente, FIDO2/WebAuthn) y hacerla obligatoria para administradores.
- [ ] Preferir contraseñas largas o frases de contraseña sobre reglas de complejidad arbitrarias.
- [ ] Apoyarse en OAuth 2.0 / OpenID Connect y frameworks maduros en lugar de esquemas propios.
- [ ] Usar mensajes de error genéricos para no revelar si una cuenta existe.
- [ ] Registrar y monitorear intentos fallidos; auditar el flujo con Burp Suite y contrastarlo con OWASP ASVS y los cheat sheets.