Nivel 3 · 30 min
Trampas de JWT: alg=none, key confusion, expiración
Los JSON Web Tokens están en todos lados porque son convenientes: autenticación stateless, autorización basada en claims, sin hit a la DB por request. También son la fuente de un catálogo largo de vulnerabilidades históricas — casi todas causadas por la decisión de diseño de dejar que el token declare su propio algoritmo. Usá JWTs, pero entendé los modos de falla.
Anatomía y el Ataque alg=none
Un JWT son tres segmentos en base64url: header.payload.signature. El header se ve así: { "alg": "HS256", "typ": "JWT" }, el payload contiene claims ({ "sub": "alice", "exp": 1700000000 }), y la firma se computa sobre header + '.' + payload usando el algoritmo declarado en el header. El ataque histórico: la spec de JWT incluye alg=none para tokens 'sin firma'. Las libs viejas verificaban la firma solo si el header decía que había un algoritmo presente — así que un atacante forjaba { "alg": "none" }.{ "sub":"admin" }. (sin firma, solo el punto final) y la lib devolvía un token verificado con privilegios de admin. El fix: hacer allowlist explícito de algoritmos del lado servidor; nunca confiar en el header. jjwt, jose y jsonwebtoken modernos rechazan alg=none por defecto, pero codebases que pinearon versiones viejas o escribieron su propio verificador todavía caen.
Key Confusion HS256 / RS256
HS256 es simétrico (HMAC-SHA-256 con un secreto compartido). RS256 es asimétrico (firma RSA con clave privada, verificada con clave pública). El ataque: el servidor está configurado para verificar con una clave pública RSA, esperando RS256. El atacante forja un token con header { "alg": "HS256" } y computa la firma usando la clave pública (que, por definición, es pública) como secreto HMAC. Una lib que selecciona el algoritmo desde el header trata la clave pública como secreto HMAC, la firma verifica, y el atacante entra como cualquiera. El fix: pinear el algoritmo en el callsite de verificación — verify(token, { algorithms: ["RS256"] }) — nunca dejar que el header elija. Auth0, Microsoft y decenas de libs OSS shippearon con este bug en algún momento. El mismo patrón aplica a ECDSA / EdDSA — siempre pineá.
Expiración, Refresh y Revocación
Los JWTs son stateless: el servidor no tiene registro de los tokens emitidos. Esto significa que la revocación (''cerrar sesión en todos lados'', ''banear este usuario'') es difícil. La solución estándar es access tokens de vida corta (5-15 minutos) más refresh tokens más largos (días, guardados del lado servidor, single-use, rotados en cada refresh). Cuando deslogueás a un usuario, borrás su refresh token de la DB; su access token sigue funcionando hasta 15 minutos, lo que es aceptable para la mayoría de los modelos de amenaza. Saltearse el refresh y emitir access tokens de 7 días significa que un token robado da 7 días de acceso — y no se puede revocar. Los claims exp y nbf deben ser validados por el verificador (''Not Before''). Tolerancia de clock skew: 60 segundos es razonable; una hora significa que un token expirado sigue funcionando una hora. Para ''cerrar sesión a todos'', rotá la clave de firma — todos los tokens firmados con la clave vieja fallan verificación inmediatamente. Para ''banear este usuario'', un blocklist pequeño de jti (JWT ID) con TTL igual a la expiración del token es barato y semi-stateless.
Code example
// VULNERABLE — algoritmo tomado del header (Node, jsonwebtoken viejo)
const payload = jwt.verify(token, publicKey);
// Si el header dice HS256, la lib usa publicKey como secreto HMAC '->' atacante gana
// CORREGIDO — pineá el algoritmo explícitamente
const payload = jwt.verify(token, publicKey, {
algorithms: ["RS256"] // hardcodeado; ignorá el alg del header
});
// VULNERABLE — decodificar sin verificar (bug muy común)
const claims = jwt.decode(token); // no chequea firma!
if (claims.role === "admin") { /* input no firmado tratado como confiable */ }
// CORREGIDO — verificar, después leer claims
const claims = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
// Emisión de tokens — access corto + refresh
const access = jwt.sign({sub: userId, role}, privateKey, {
algorithm: "RS256",
expiresIn: "15m",
issuer: "https://auth.example.com",
audience: "api.example.com"
});
const refresh = crypto.randomBytes(32).toString("base64url");
await db.refreshTokens.insert({token: refresh, userId, expiresAt: now + 7*24*3600*1000});
// En el refresh: rotá (invalidá el viejo, emití uno nuevo)