Hace unos meses, durante una auditoría de seguridad en una plataforma Fintech que manejaba más de 50,000 usuarios activos diarios, nos encontramos con una pesadilla silenciosa: el secuestro de sesiones persistente. A pesar de tener Access Tokens de vida corta (15 minutos), detectamos que un atacante había logrado exfiltrar un Refresh Token válido a través de una vulnerabilidad XSS menor en un módulo de terceros. Como el Refresh Token tenía una validez de 7 días y no cambiaba, el atacante mantenía el acceso mucho después de que parcheamos el XSS.
Este escenario es el talón de Aquiles de la Seguridad JWT puramente "stateless". Si un token se filtra, es válido hasta que expira. En este artículo, detallaré cómo implementé la Rotación de Refresh Token con detección de reutilización, una técnica obligatoria según las mejores prácticas modernas de OAuth 2.0, para invalidar automáticamente sesiones comprometidas.
El Problema de la Persistencia y el Análisis de Causa Raíz
El sistema original utilizaba una arquitectura estándar: un servidor Node.js (Express) emitiendo JWTs firmados con RS256. El cliente almacenaba los tokens en localStorage (un error común que corregimos moviéndolos a cookies HttpOnly, aunque esto no detiene peticiones autenticadas vía XSS si no hay CSRF protection). El problema real no era el almacenamiento, sino la falta de invalidación.
En los Sistemas de autenticación tradicionales, el servidor no recuerda el token emitido. Simplemente verifica la firma. Esto significa que:
- No puedes revocar un token específico sin cambiar la clave privada (lo que desconecta a TODOS los usuarios).
- Si un atacante roba el Refresh Token, puede solicitar nuevos Access Tokens indefinidamente.
sub (ID de usuario) y el mismo Refresh Token en un lapso de 5 minutos. El sistema lo permitía porque el token era criptográficamente válido.
Por qué falló el enfoque de "Lista Blanca" simple
Mi primer intento para mitigar esto fue almacenar los Refresh Tokens válidos en Redis. La lógica era simple: "Si el token no está en Redis, no es válido".
Esto falló miserablemente por dos razones:
- No detectaba el robo: Si el atacante usaba el token antes que el usuario legítimo (o viceversa), el sistema simplemente renovaba la sesión del primero que llegaba. No había forma de saber que había dos actores usando la misma credencial.
- Condiciones de carrera: En redes lentas, el usuario enviaba dos peticiones, la primera borraba el token de Redis y la segunda fallaba, cerrando la sesión del usuario legítimo erróneamente.
La Solución: Rotación con Detección de Reutilización
La estrategia definitiva para elevar la Seguridad Web en este contexto es la rotación estricta (Refresh Token Rotation). El flujo funciona así:
- Cada vez que se usa un Refresh Token, se invalida el usado y se emite uno completamente nuevo.
- Ambos tokens pertenecen a una "Familia de Tokens".
- La Trampa de Seguridad: Si el servidor recibe un Refresh Token que ya ha sido usado (invalidado), asume matemáticamente que ha ocurrido un robo. ¿Por qué? Porque el usuario legítimo ya debería tener el nuevo token. Si alguien presenta el viejo, es un atacante o una condición de carrera.
- Acción: Se invalida TODA la familia de tokens, forzando el cierre de sesión tanto del atacante como del usuario legítimo (quien recibirá una alerta de seguridad).
Implementación Técnica (Node.js + SQL)
A continuación, presento la lógica del controlador que gestiona la rotación. Es crucial notar el uso de transacciones para evitar inconsistencias.
// Modelo de datos conceptual:
// RefreshTokens(id, token_hash, family_id, is_used, expires_at, user_id)
async function handleRefreshToken(incomingToken) {
// 1. Buscar el token en la base de datos
const storedToken = await db.findToken({ token: incomingToken });
if (!storedToken) {
throw new Error("Token no reconocido - Posible ataque de fuerza bruta");
}
// 2. DETECCIÓN DE REUTILIZACIÓN (El núcleo de la seguridad)
if (storedToken.is_used) {
// ALERTA CRÍTICA: Alguien está intentando usar un token viejo.
// Esto significa que el usuario legítimo ya lo rotó, y esto es un atacante,
// O el atacante lo usó primero y el usuario legítimo está intentando usarlo.
console.warn(`[SECURITY] Robo de token detectado para la familia ${storedToken.family_id}`);
// Contramedida: Invalidar TODA la familia de tokens (Logout forzado global)
await db.deleteAllTokensInFamily(storedToken.family_id);
throw new SecurityException("Sesión comprometida. Por favor inicie sesión nuevamente.");
}
// 3. Validar expiración y firma
if (isExpired(storedToken)) {
throw new Error("Token expirado");
}
// 4. Rotación Exitosa
const newFamilyId = storedToken.family_id; // Mantenemos la familia
const newAccessToken = generateAccessToken(storedToken.user_id);
const newRefreshToken = generateRefreshToken();
await db.transaction(async (trx) => {
// A. Marcar el token actual como usado
await trx.updateToken(storedToken.id, { is_used: true });
// B. Insertar el nuevo token vinculado a la misma familia
await trx.insertToken({
token: newRefreshToken,
family_id: newFamilyId,
user_id: storedToken.user_id,
is_used: false
});
});
return { newAccessToken, newRefreshToken };
}
En este código, la línea que verifica if (storedToken.is_used) es la que diferencia un sistema vulnerable de uno robusto compatible con OAuth 2.0 Best Practices. Sin esta verificación, la rotación es solo cosmética.
| Estrategia | Seguridad contra Robo | Impacto en BD | Experiencia de Usuario (UX) |
|---|---|---|---|
| JWT Stateless (Básico) | Nula (Válido hasta expirar) | Ninguno | Alta |
| Lista Negra (Redis) | Media (Depende del TTL) | Alto (RAM) | Media |
| Rotación de Refresh Token | Muy Alta (Autodetección) | Medio (1 Write/Refresh) | Alta (Transparente) |
La tabla anterior demuestra que, aunque introducimos una escritura en base de datos por cada renovación de access token (usualmente cada 15-30 minutos), la ganancia en seguridad justifica plenamente la carga adicional. En nuestro caso, con PostgreSQL optimizado, el impacto en latencia fue inferior a 12ms.
El Caso Borde: Concurrencia en el Frontend
Implementar esto generó un efecto secundario inesperado en React. Cuando la aplicación cargaba, disparaba múltiples peticiones a la API simultáneamente (ej. /api/user, /api/dashboard, /api/notifications). Si el Access Token había expirado, las tres peticiones intentaban refrescar el token al mismo tiempo.
La primera petición rotaba el token. La segunda y tercera llegaban milisegundos después con el token "viejo" (que la primera petición acababa de invalidar). Resultado: El servidor detectaba "robo" y cerraba la sesión del usuario.
Consideraciones Finales y Advertencias
Antes de desplegar esto, considera:
- Sincronización de Relojes: Aunque JWT maneja
iatyexp, la base de datos es la fuente de la verdad para el estadois_used. Asegúrate de que tus zonas horarias sean consistentes. - Costos de Almacenamiento: La tabla de
refresh_tokenscrecerá indefinidamente si no tienes una tarea programada (CRON) para eliminar los tokens expirados o las familias de tokens antiguas. - Experiencia Móvil: En redes inestables (4G/5G en movimiento), la rotación puede fallar si la respuesta con el nuevo token no llega al cliente. El cliente se queda con el viejo (ya invalidado). El reintento fallará. Maneja esto con lógica de reintento inteligente o ventanas de gracia breves.
Conclusión
La Rotación de Refresh Token no es solo una característica "agradable de tener", sino una necesidad crítica para cualquier aplicación moderna que aspire a cumplir con los estándares de JWT y seguridad empresarial. Transforma una vulnerabilidad pasiva en un sistema de detección de intrusos activo. Aunque añade complejidad al backend y requiere un manejo cuidadoso de la concurrencia en el frontend, la capacidad de invalidar sesiones robadas instantáneamente es invaluable.
Post a Comment