Rotación de Refresh Token: Cómo detuve el robo de sesiones JWT en Producción

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.
El síntoma crítico: Los logs de acceso mostraban peticiones desde IPs geográficamente imposibles (ej. Berlín y Buenos Aires) con el mismo 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:

  1. 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.
  2. 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í:

  1. Cada vez que se usa un Refresh Token, se invalida el usado y se emite uno completamente nuevo.
  2. Ambos tokens pertenecen a una "Familia de Tokens".
  3. 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.
  4. 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.

Solución de Concurrencia: Implementamos un mecanismo de "token promise singleton" en el interceptor de Axios. Si ya hay una petición de refresh en curso, las demás peticiones esperan a esa promesa en lugar de lanzar una nueva llamada de renovación. Alternativamente, se puede dar una "ventana de gracia" de 5 segundos en el servidor donde el token viejo aún se acepta, pero esto reduce ligeramente la seguridad.

Consideraciones Finales y Advertencias

Antes de desplegar esto, considera:

  • Sincronización de Relojes: Aunque JWT maneja iat y exp, la base de datos es la fuente de la verdad para el estado is_used. Asegúrate de que tus zonas horarias sean consistentes.
  • Costos de Almacenamiento: La tabla de refresh_tokens crecerá 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.
Resultado: Tras implementar la rotación, detectamos y neutralizamos 4 intentos reales de secuestro de sesión en la primera semana, demostrando que las credenciales habían sido comprometidas previamente sin nuestro conocimiento.

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