OWASP A07 Fallos de Autenticación
OWASP A07:2021 fallos de autenticación con ejemplos reales: tokens en localStorage, JWT sin expiración, sin rate limiting, user enumeration y cómo corregir cada uno.
Rod
Founder & Developer
Los fallos de autenticación de OWASP (A07:2021) bajaron del puesto #2 en 2017 al #7 en 2021. Suena a progreso, y en cierta forma lo es — los frameworks modernos tienen mejores defaults de auth que hace cinco años. Pero la bajada ocurrió porque la categoría se volvió más estrecha, no porque los bugs desaparecieran. JWT sin expiración, endpoints de login sin rate limiting, tokens en localStorage, flujos OAuth sin el parámetro state — seguimos viendo estos en repos reales cada semana.
Esta guía cubre cada fallo común en A07 con ejemplos de código antes/después y soluciones concretas. Sin consejos vagos. Solo lo que hay que revisar y cambiar.
Por Qué los Fallos de Autenticación Siguen en el OWASP Top 10
La reorganización de 2021 fusionó la antigua categoría "Broken Authentication" con "Broken Session Management" y la renombró "Identification and Authentication Failures". OWASP citó los mejores defaults de los frameworks como razón principal de la bajada en el ranking.
El problema: los frameworks te dan las herramientas para implementar auth correctamente. No pueden hacer que las uses correctamente.
La brecha de Uber en 2022 comenzó con credential stuffing — un atacante usó credenciales filtradas para acceder a la cuenta de un contratista. Luego usó MFA fatigue (enviando notificaciones MFA repetidamente hasta que el contratista aceptó una) para entrar. Ningún zero-day exótico. Solo persistencia contra un flujo de auth que carecía de bloqueo de cuenta después de múltiples intentos fallidos de MFA.
La brecha de Okta en 2022 involucró credenciales de sistema de soporte comprometidas con tiempos de sesión excesivos. Una cuenta de soporte tenía sesiones que permanecían válidas durante semanas sin re-autenticación.
No son vulnerabilidades teóricas. Son gaps de implementación específicos y corregibles.
Los 8 Fallos de Autenticación que Debes Revisar
1. Tokens Guardados en localStorage
Este es el que más confunde a los desarrolladores que empiezan con auth. Se siente natural — localStorage es simple, persistente y fácil de acceder desde JavaScript. Exactamente por eso es peligroso.
// MAL: Cualquier script en tu página puede leer esto
localStorage.setItem('token', jwtToken);
// Más tarde...
const token = localStorage.getItem('token');
fetch('/api/data', {
headers: { Authorization: `Bearer ${token}` }
});Cualquier vulnerabilidad XSS en cualquier parte de tu sitio — en un script de terceros, en contenido generado por el usuario, en un anuncio — puede robar este token. El atacante no necesita acceso a tus servidores. Solo necesita una línea de JavaScript inyectado que corra en el navegador del usuario.
// BIEN: Cookie httpOnly — JavaScript no puede leerla en absoluto
// Configura esto en el servidor cuando el usuario hace login
res.setHeader('Set-Cookie', [
`token=${jwtToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600`
]);
// El navegador la envía automáticamente — no se necesita JS
fetch('/api/data'); // cookie incluida automáticamenteEl flag HttpOnly le dice al navegador: "Nunca permitas que JavaScript acceda a esta cookie." El flag Secure asegura que solo viaje por HTTPS. SameSite=Strict previene cross-site request forgery. Juntos, hacen el token inaccesible para scripts inyectados.
Lee más sobre esta vulnerabilidad específica en nuestra referencia de tokens en localStorage.
2. JWT Sin Expiración
Un JWT sin claim exp es una credencial permanente. Piérdelo una vez y es válido hasta que tu servidor se reinicie, tu clave de firma rote, o implementes una blocklist (lo cual anula gran parte del punto de los JWTs).
// MAL: Sin claim exp — este token es válido para siempre
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET
// Sin opción expiresIn = sin claim exp
);// BIEN: Access token de vida corta + patrón de refresh token
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // expira en 15 minutos
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' } // vida más larga, guardado en cookie httpOnly
);
// Envía el access token en el body de respuesta o en una cookie de corta duración
// Envía el refresh token como cookie httpOnlyEl patrón: los access tokens expiran en 15 minutos a 1 hora. Los refresh tokens duran más (7–30 días) pero se guardan en cookies httpOnly y se rotan en cada uso. Si un access token es robado, es válido por máximo 15 minutos. Si un refresh token es robado, lo detectas por rotación (el token viejo es inválido después de usarse, entonces un intento de reuso señala el robo).
Revisa la referencia completa de JWT sin expiración.
3. Cookies Sin el Flag HttpOnly
Incluso si no usas localStorage, la mala configuración de cookies es su propio fallo. Una cookie con httpOnly: false es legible por JavaScript — la misma exposición XSS, diferente mecanismo de almacenamiento.
// MAL: Cookie de sesión legible por cualquier JavaScript en la página
res.cookie('session', sessionToken, {
secure: true,
// httpOnly es false por default en la mayoría de las librerías
});// BIEN: HttpOnly + Secure + SameSite = protección completa
res.cookie('session', sessionToken, {
httpOnly: true, // sin acceso JS
secure: true, // solo HTTPS
sameSite: 'strict', // sin envío cross-site
maxAge: 3600000 // 1 hora en milisegundos
});Revisa tu configuración de cookies sin flag httpOnly ahora mismo. Es un fix de una línea.
4. Sin Rate Limiting en el Endpoint de Login
Un endpoint /login sin protección acepta intentos de contraseña ilimitados. Esto habilita dos tipos de ataques: fuerza bruta (probar contraseñas sistemáticamente) y credential stuffing (probar pares de usuario/contraseña filtrados de otras brechas).
// MAL: Sin rate limiting — un atacante puede probar 10,000 contraseñas/minuto
export async function POST(req: Request) {
const { email, password } = await req.json();
const user = await verifyCredentials(email, password);
// ...
}// BIEN: Rate limit por IP antes de procesar la solicitud
import { rateLimit } from '@/lib/rate-limit';
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
// 5 intentos por minuto por IP
const { success } = await rateLimit(ip, { limit: 5, window: 60 });
if (!success) {
return Response.json(
{ error: 'Demasiados intentos de login. Intenta más tarde.' },
{ status: 429 }
);
}
const { email, password } = await req.json();
const user = await verifyCredentials(email, password);
// ...
}Combina tus protecciones: rate limiting por IP en el edge (antes de que la solicitud llegue a tu servidor), bloqueo de cuenta después de N intentos fallidos, y backoff exponencial que aumenta el tiempo de espera con cada fallo. Revisa sin bloqueo de cuenta para el patrón de implementación completo.
5. User Enumeration por Mensajes de Error
Esta es sutil. Tu aplicación probablemente tiene mensajes de error diferentes para "el email no existe" y "contraseña incorrecta." Es útil para los usuarios. También es un mapa para los atacantes.
// MAL: Mensajes diferentes revelan si el email está registrado
if (!user) {
return Response.json({ error: 'Email no encontrado' }, { status: 401 });
}
if (!passwordMatches) {
return Response.json({ error: 'Contraseña incorrecta' }, { status: 401 });
}Un atacante puede automatizar solicitudes con una lista de emails. Cualquier respuesta con "Email no encontrado" le dice que esa cuenta no existe. "Contraseña incorrecta" le dice que sí existe. Ahora tiene una lista verificada de cuentas registradas — perfecta para una campaña de credential stuffing.
// BIEN: El mismo mensaje sin importar cuál verificación falló
if (!user || !passwordMatches) {
return Response.json(
{ error: 'Credenciales inválidas' }, // mismo mensaje siempre
{ status: 401 }
);
}Los ataques de timing son la otra cara de esto. Si verificar un usuario inexistente regresa en 1ms (sin comparación de hash) pero una contraseña incorrecta regresa en 100ms (comparación bcrypt), el tiempo de respuesta revela la misma información que mensajes de error diferentes. Usa una comparación de hash ficticia de tiempo constante cuando el usuario no existe.
Aprende más sobre las vulnerabilidades de user enumeration y cómo prevenir las fugas basadas en timing.
6. OAuth Sin el Parámetro State
Si tu aplicación usa OAuth (login con GitHub, Google, etc.) sin el parámetro state, eres vulnerable a ataques CSRF en el flujo OAuth. Un atacante puede crear un enlace que, al hacer clic, completa un login OAuth que vincula la cuenta externa del atacante a la sesión existente de la víctima.
// MAL: Redirect OAuth sin parámetro state
const authUrl = `https://github.com/login/oauth/authorize?
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
scope=read:user`;
// Sin state = sin protección CSRF en el callback// BIEN: Genera state aleatorio, guárdalo en sesión, verifica en callback
import { randomBytes } from 'crypto';
// Al iniciar OAuth
const state = randomBytes(32).toString('hex');
// Guarda en sesión del servidor o en cookie firmada
req.session.oauthState = state;
const authUrl = `https://github.com/login/oauth/authorize?
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
scope=read:user&
state=${state}`;
// En el callback — verifica que el state coincida antes de intercambiar el code
export async function GET(req: Request) {
const { code, state } = Object.fromEntries(new URL(req.url).searchParams);
if (state !== req.session.oauthState) {
return Response.json({ error: 'Parámetro state inválido' }, { status: 400 });
}
// Seguro para intercambiar code por token
}Revisa la referencia completa de OAuth sin parámetro state para todos los casos borde.
7. Sin Invalidación de Sesión al Cambiar Contraseña o Cerrar Sesión
Cuando un usuario cambia su contraseña, todas las sesiones existentes deben invalidarse inmediatamente. Si no, un atacante que robó un token de sesión sigue teniendo acceso incluso después de que el usuario "aseguró" su cuenta.
Lo mismo aplica al logout. Un "cerrar sesión" que solo limpia el token del lado del cliente pero deja la sesión del servidor activa no hace nada contra un token robado.
// MAL: El cambio de contraseña no invalida otras sesiones
async function changePassword(userId: string, newPassword: string) {
const hashed = await bcrypt.hash(newPassword, 12);
await db.users.update({ where: { id: userId }, data: { password: hashed } });
// Las sesiones existentes siguen válidas — el atacante mantiene acceso
}// BIEN: Rota el secreto de sesión o invalida todas las sesiones al cambiar contraseña
async function changePassword(userId: string, newPassword: string) {
const hashed = await bcrypt.hash(newPassword, 12);
await db.$transaction([
// Actualiza la contraseña
db.users.update({ where: { id: userId }, data: { password: hashed } }),
// Elimina todas las sesiones existentes para este usuario
db.sessions.deleteMany({ where: { userId } }),
]);
}Con JWTs (stateless), el enfoque es diferente: incluye un timestamp passwordChangedAt en la lógica de validación del token. Rechaza cualquier token emitido antes del último cambio de contraseña.
8. Política de Contraseñas Débil
Las Pautas de Identidad Digital de NIST (SP 800-63B) actualizaron el estándar en 2017. El consejo antiguo (caracteres especiales, rotación forzada, reglas de complejidad) en realidad empeora las contraseñas al hacerlas predecibles y fomentar la reutilización. El nuevo estándar: mínimo 8 caracteres (NIST recomienda 15+), sin rotación forzada, verificar contra listas de contraseñas conocidas y comprometidas.
// MAL: Reglas de complejidad arbitrarias que no mejoran la seguridad
function isValidPassword(password: string): boolean {
return (
password.length >= 8 &&
/[A-Z]/.test(password) && // mayúscula requerida
/[0-9]/.test(password) && // número requerido
/[!@#$]/.test(password) // caracter especial requerido
);
// Resultado: los usuarios eligen "Contraseña1!" — cumple las reglas, trivialmente adivinable
}// BIEN: Política basada en longitud + verificación de contraseñas comprometidas
import { checkHIBP } from '@/lib/hibp'; // Have I Been Pwned API
async function isValidPassword(password: string): Promise<boolean> {
if (password.length < 12) return false;
// Verifica contra contraseñas conocidas comprometidas
const isBreached = await checkHIBP(password);
if (isBreached) return false;
return true;
}La API de Have I Been Pwned Passwords usa k-anonimato — envías los primeros 5 caracteres del hash SHA-1, recibes hashes coincidentes, y verificas localmente. La contraseña real nunca sale de tu servidor. Revisa la referencia de política de contraseñas débil para una implementación completa.
Una Brecha Real: Uber 2022 y el MFA Fatigue
En septiembre de 2022, un atacante comprometió los sistemas internos de Uber usando una técnica llamada MFA fatigue. Esto es lo que pasó:
- El atacante obtuvo las credenciales del contratista de Uber — probablemente a través de un data broker o una brecha previa.
- Intentó hacer login repetidamente. El MFA estaba habilitado, entonces cada intento enviaba una notificación al celular del contratista.
- Después de decenas de notificaciones push, el atacante le envió un mensaje de WhatsApp al contratista haciéndose pasar por soporte de IT: "Estás recibiendo esto porque necesitamos verificar tu cuenta. Por favor acepta."
- El contratista aceptó. El atacante entró.
Ninguna vulnerabilidad de código explotada. Solo persistencia contra un flujo MFA que permitía notificaciones push ilimitadas sin bloqueo.
El fix no es complejo: limita las notificaciones push de MFA a 3–5 por hora por cuenta. Después de ese umbral, requiere un factor diferente (código TOTP, verificación por email) y marca la cuenta para revisión. El number-matching en aplicaciones MFA (mostrar el mismo número en la app y en la página de login) también elimina esto — el usuario tiene que confirmar activamente que él inició la solicitud.
No hay situación de sin MFA/2FA más fácil de defender que una donde simplemente implementas rate limiting básico en el MFA.
Prevención: Usa Auth Battle-Tested, No DIY
La parte más difícil de la seguridad en autenticación no es conocer las reglas. Es implementarlas todas correctamente, todo el tiempo, en cada proyecto nuevo. La mayoría de los bugs de auth son bugs de omisión — alguien olvidó el parámetro state, o no agregó expiración al JWT, o nunca llegó al rate limiter.
La respuesta pragmática: no construyas auth desde cero.
Supabase Auth maneja el hashing de contraseñas, flujos de confirmación de email, parámetros state de OAuth, gestión de sesiones y rotación de tokens. Su documentación de auth cubre todo esto. Si ya estás en Supabase, tienes defaults de auth sólidos disponibles — solo necesitas configurarlos correctamente (tiempos de sesión cortos, MFA habilitado, confirmación de email requerida).
NextAuth.js / Auth.js es el estándar para aplicaciones Next.js. Maneja parámetros state de OAuth, protección CSRF y gestión de sesiones por default. El almacenamiento en cookies httpOnly es el default. Tendrías que trabajar en contra de los defaults para guardar tokens de forma insegura.
Auth0 y servicios similares gestionados transfieren toda la superficie de auth a especialistas. Vale el costo si manejas datos sensibles y no quieres mantener la capa de auth tú mismo.
Usar una librería no te hace inmune. Igual necesitas configurar tiempos de token cortos, habilitar MFA, agregar rate limiting en tus propios endpoints, y verificar que tus integraciones OAuth usen el parámetro state. Las librerías te dan la base — tú todavía tienes que construir correctamente sobre ella.
Para una lista completa de lo que verificar sin importar qué librería uses, revisa la referencia de fallos de autenticación en nuestra enciclopedia de seguridad.
El Checklist: Qué Auditar Ahora Mismo
Pasa por estos en tu proyecto actual:
| Verificación | Riesgo si falta | Fix |
|---|---|---|
| Tokens en localStorage | Robo de token por XSS | Mover a cookie httpOnly |
JWT sin claim exp |
Tokens robados válidos para siempre | Agregar expiresIn: '15m' |
Sin rate limit en /login |
Fuerza bruta / credential stuffing | 5 req/min por IP, bloqueo de cuenta |
| User enumeration en mensajes de error | Cosecha de cuentas | Mensaje único "Credenciales inválidas" |
| OAuth sin parámetro state | CSRF en flujo OAuth | Generar y verificar state aleatorio |
| Sesiones sobreviven al cambio de contraseña | Atacante mantiene acceso post-compromiso | Invalida todas las sesiones al cambiar contraseña |
| Política de contraseñas débil | Credenciales fácilmente adivinables | 12+ chars + verificación HIBP |
| Sin MFA | Punto único de fallo | TOTP o push con rate limiting |
| Cookies sin HttpOnly | JavaScript puede leer el token de sesión | Configurar httpOnly: true |
| Flujo de reset de contraseña inseguro | Toma de cuenta por link de reset | Tokens de un solo uso, expiración corta, con rate limit |
Escanea Tu Aplicación en Busca de Vulnerabilidades de Autenticación
Escaneamos fallos de autenticación, JWT sin expiración, tokens en localStorage, cookies sin HttpOnly, sin bloqueo de cuenta, política de contraseñas débil, OAuth sin parámetro state, user enumeration, reset de contraseña inseguro, y sin MFA/2FA — junto con más de 180 verificaciones adicionales — en cada escaneo de repositorio.
Cuando escaneamos una muestra de 50 repositorios Next.js en producción, 34 de ellos tenían al menos un hallazgo relacionado con autenticación. El más común: tokens JWT sin expiración (22 repos), seguido de sin rate limiting en endpoints de login (18 repos), y tokens guardados en localStorage (11 repos).
Escanea tu repo gratis y mira tus hallazgos de auth →
El escaneo conecta vía GitHub OAuth (solo lectura), corre en aproximadamente 60 segundos, y te da una lista priorizada de hallazgos con ubicaciones específicas de archivos y sugerencias de fix. Sin tarjeta de crédito requerida para los primeros 3 escaneos.
Preguntas Frecuentes
¿Qué es OWASP A07:2021 Fallos de Identificación y Autenticación?
OWASP A07:2021 cubre las debilidades en cómo las aplicaciones verifican la identidad del usuario y gestionan las sesiones. Incluye políticas de contraseña débiles, sin protección contra fuerza bruta, JWT sin expiración, tokens en localStorage, sin invalidación de sesión al cerrar sesión, falta de MFA, vulnerabilidades de user enumeration, y flujos OAuth sin el parámetro state. Estaba en el puesto #2 en 2017 y bajó al #7 en 2021 porque los frameworks modernos mejoraron sus defaults — pero los bugs siguen apareciendo constantemente en repos reales.
¿Por qué es peligroso guardar tokens JWT en localStorage?
localStorage es accesible para cualquier JavaScript que corra en tu página, incluyendo scripts de terceros y código inyectado mediante ataques XSS. Si un atacante puede ejecutar JavaScript en el navegador del usuario (a través de una sola vulnerabilidad XSS en cualquier parte de tu sitio), puede leer el JWT de localStorage y usarlo para hacerse pasar por ese usuario. Guardar tokens en cookies httpOnly los hace invisibles para JavaScript — el navegador los envía automáticamente con cada solicitud, pero ningún script puede leerlos o robarlos.
¿Cómo protejo mi endpoint de login contra ataques de fuerza bruta?
Como mínimo: rate limiting por IP (5-10 intentos por minuto), backoff exponencial después de intentos fallidos, bloqueo de cuenta después de un umbral (10-20 fallos), y registro de intentos fallidos con IP y timestamp. Para protección más sólida, agrega CAPTCHA después de los primeros fallos. En Next.js, puedes usar middleware para aplicar rate limiting antes de que la solicitud llegue a tu API route. Nunca dependas de un solo mecanismo — combínalos.
¿Qué claims son necesarios en un JWT para un manejo seguro de tokens?
Todo JWT debe incluir: exp (tiempo de expiración — corto, de 15 minutos a 1 hora para access tokens), iat (emitido en), sub (sujeto — el ID del usuario), e iss (emisor — tu dominio). Sin exp, un token robado es válido para siempre. Sin sub específico por usuario, puedes ser vulnerable a ataques de sustitución de tokens. Usa refresh tokens (guardados en cookies httpOnly) para emitir nuevos access tokens sin forzar re-login.
¿Qué es user enumeration y por qué importa?
User enumeration ocurre cuando tu aplicación revela si una dirección de email está registrada, ya sea mediante mensajes de error diferentes ('Email no encontrado' vs 'Contraseña incorrecta') o tiempos de respuesta distintos. Un atacante puede automatizar solicitudes con una lista de emails para construir una lista de cuentas válidas, perfecta para ataques de credential stuffing. La solución es simple: siempre devuelve el mismo mensaje genérico ('Credenciales inválidas') y toma el mismo tiempo de respuesta, sin importar si el email existe o no.
La autenticación es una de esas áreas donde la diferencia entre "funciona" y "es seguro" no es obvia hasta que algo sale mal. Los bugs en esta guía no son casos extremos — son exactamente los problemas detrás de brechas reales en empresas bien financiadas con equipos de seguridad dedicados.
La buena noticia: todos y cada uno son corregibles en una tarde.
Escanea tu repo en busca de vulnerabilidades de auth → | Lee la referencia de fallos de autenticación →
Posts Relacionados
OWASP A03 Ataques de Inyección
OWASP A03:2021 Inyección cubre SQL, NoSQL, XSS, inyección de comandos y más. Código vulnerable vs. seguro en Node.js, un breach real, y cinco estrategias de prevención.
OWASP A05 Configuración Insegura
El 90% de las aplicaciones tiene alguna configuración insegura. Aprende qué cubre OWASP A05:2021, ve el código vulnerable vs. seguro en Next.js, y corrígelo ya.
OWASP A01 Control de Acceso Roto
El control de acceso roto es el riesgo #1 del OWASP Top 10. Esta guía explica IDOR, JWT tampering y rutas sin auth — con ejemplos reales en Next.js y cómo corregirlos.