JWT y sus trampas
En resumen: Un JWT es un token firmado hecho de un header, un payload y una firma; el payload no está cifrado y cualquiera puede leerlo, y el servidor debe comprobar por sí mismo el algoritmo, la firma y exp.
Problema
El token que se volvió admin
La aplicación móvil de la tienda no usa páginas sino una API. El equipo elige JWT, «para que el servidor no tenga que guardar sesiones», y copia la comprobación del token de una entrada vieja de un blog. Meses después un investigador de seguridad escribe a la tienda: con un token que escribió a mano, entró en la API de devoluciones como admin.
La clave de la tienda no se ha filtrado. La contraseña de admin tampoco. El investigador no rompió nada: la comprobación aceptó su token por sí sola.
La idea
Un pase firmado
La sesión de la lección anterior es un número de guardarropa: el servidor guarda una tabla de quién hay detrás de cada id. Un JWT (JSON Web Token) es otro enfoque: el propio token dice quién eres y hasta cuándo es válido, y el servidor lo firma para que no se pueda cambiar.
El token tiene tres partes, separadas por puntos:
- header: cómo está firmado, por ejemplo
{"alg":"HS256","typ":"JWT"}; - payload: lo que dice, por ejemplo
{"sub":"maria","exp":1793000900}.subes el usuario,expes el momento en que caduca, en segundos desde el 1 de enero de 1970; - signature: HMAC-SHA256 sobre "header.payload" con una clave secreta del servidor.
El header y el payload son JSON escrito en base64url: base64 con - y _ en lugar de + y / y sin = al final, para que quepa en una dirección y en un encabezado. Base64url es una forma de escribir, no un cifrado. El token está firmado, no secreto: cualquiera lo lee, pero sin la clave nadie puede cambiarlo sin que la firma deje de coincidir.
Míralo
Las tres partes y el token falsificado
function verify(token, now) { const parts = token.split("."); if (parts.length !== 3) return "✗ formato"; // el algoritmo está fijado: nunca se toma del token const { alg } = JSON.parse(decode(parts[0])); if (alg !== "HS256") return "✗ algoritmo"; const data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ firma"; const claims = JSON.parse(decode(parts[1])); if (typeof claims.exp !== "number" || now >= claims.exp) return "✗ expirado"; return "✓ " + claims.sub;}function issue(user, exp) { const header = { alg: "HS256", typ: "JWT" }; const payload = { sub: user, exp }; const data = encode(header) + "." + encode(payload); return data + "." + sign(data);}// ASÍ NO: confía en el algoritmo del tokenfunction naiveVerify(token) { const parts = token.split("."); const { alg } = JSON.parse(decode(parts[0])); if (alg === "HS256") { if (sign(parts[0] + "." + parts[1]) !== parts[2]) return "✗ firma"; } // "none": el propio token dice que no tiene firma return "✓ " + JSON.parse(decode(parts[1])).sub;}function verify(token, now) { const parts = token.split("."); if (parts.length !== 3) return "✗ formato"; // el algoritmo está fijado: nunca se toma del token const { alg } = JSON.parse(decode(parts[0])); if (alg !== "HS256") return "✗ algoritmo"; const data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ firma"; const claims = JSON.parse(decode(parts[1])); if (typeof claims.exp !== "number" || now >= claims.exp) return "✗ expirado"; return "✓ " + claims.sub;}Paso a paso, en palabras
- El header dice cómo está firmado el token: HS256. El JSON se convierte en texto base64url.
- El payload dice quién y hasta cuándo: sub maria, exp en 15 minutos. También base64url.
- La firma es HMAC-SHA256 sobre "header.payload" con la clave del servidor. Sin la clave nadie puede hacerla.
- El token son las tres partes con puntos. La aplicación móvil lo envía en cada solicitud.
- Cualquiera que tenga el token lee el payload sin clave: base64url no es cifrado. Firmado, no secreto.
- Ahora un token falsificado: el header dice alg none, el payload dice sub admin, y no hay firma.
- La comprobación ingenua le pregunta al token qué algoritmo usar. "none" se salta la firma, y admin entra.
- El arreglo fija el algoritmo: solo HS256, decidido por el servidor. alg none se rechaza antes que nada.
- Un token auténtico pero viejo: la firma es correcta, pero exp ya pasó. La comprobación ingenua lo dejó entrar; esta no.
- Tres comprobaciones, en este orden: el algoritmo que eligió el servidor, la firma, la caducidad. El payload nunca es un secreto.
La firma y base64url están en la biblioteca estándar de cada lenguaje. La clave de aquí es de demostración: la real son 32 bytes aleatorios guardados fuera del código (la lección «Secretos en el código y en git»).
const KEY = "demo-key-only-for-this-lesson"; // una clave de demostración; la real son 32 bytes aleatorios de un almacén de secretos
const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");const sign = (data) => createHmac("sha256", KEY).update(data).digest("base64url");function issue(user, exp) { const header = { alg: "HS256", typ: "JWT" }; const payload = { sub: user, exp }; const data = encode(header) + "." + encode(payload); return data + "." + sign(data);}JSON.stringify escribe sin espacios, y Buffer y createHmac de node:crypto conocen base64url ("base64url").Un token para María, válido 15 minutos:
const now = 1_793_000_000;const token = issue("maria", now + 900);const parts = token.split(".");console.log("header: " + parts[0]);console.log("payload: " + parts[1]);console.log("firma: " + parts[2]);console.log("payload sin la clave:", decode(parts[1]));header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0firma: mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKwpayload sin la clave: {"sub":"maria","exp":1793000900}La clave y el «ahora» son fijos, así que el token es el mismo en cada ejecución y en los cuatro lenguajes. La última línea es el payload, leído sin la clave.
Pruébalo
Predice la salida
Alguien ha atrapado el token de María. No hay clave. ¿Qué imprime esto?
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw";const payload = token.split(".")[1];console.log(Buffer.from(payload, "base64url").toString());La parte del medio es solo base64url. Se decodifica sin clave y da todo el JSON, con exp. Por eso nunca van secretos en el payload.
La idea
Trampa 1: el algoritmo viene del token
Aquí está la comprobación de la entrada del blog. Para leer el header y el payload usa dos funciones auxiliares:
const decode = (part) => Buffer.from(part, "base64url").toString();// ASÍ NO: confía en el algoritmo del tokenfunction naiveVerify(token) { const parts = token.split("."); const { alg } = JSON.parse(decode(parts[0])); if (alg === "HS256") { if (sign(parts[0] + "." + parts[1]) !== parts[2]) return "✗ firma"; } // "none": el propio token dice que no tiene firma return "✓ " + JSON.parse(decode(parts[1])).sub;}Lee alg del header y comprueba la firma solo si ahí dice HS256. El estándar sí tiene un algoritmo none: «un token sin firma». Aquí hay cuatro tokens: el auténtico, el mismo con el payload cambiado, un token escrito a mano con alg: none, sub: admin y una firma vacía, y un token auténtico pero caducado:
const now = 1_793_000_000;const good = issue("maria", now + 900);const p = good.split(".");const admin = encode({ sub: "admin", exp: now + 900 });// un payload modificado con la firma viejaconst changed = p[0] + "." + admin + "." + p[2];// alg: none y ninguna firmaconst none = encode({ alg: "none", typ: "JWT" }) + "." + admin + ".";const old = issue("maria", now - 60);console.log("auténtico:", naiveVerify(good));console.log("payload modificado:", naiveVerify(changed));console.log("alg: none:", naiveVerify(none));console.log("expirado:", naiveVerify(old));auténtico: ✓ mariapayload modificado: ✗ firmaalg: none: ✓ adminexpirado: ✓ mariaUn payload cambiado se detecta, porque la firma ya no coincide. Pero alg: none deja entrar a admin sin ninguna firma, y el token caducado pasa, porque nunca se mira exp. El header es entrada del usuario, como todo lo que hay en el token hasta que se comprueba la firma (la lección «Entrada no confiable»). Una comprobación que le pregunta al token cómo comprobarlo confía en el atacante.
Míralo
La solución: tres comprobaciones en orden
function verify(token, now) { const parts = token.split("."); if (parts.length !== 3) return "✗ formato"; // el algoritmo está fijado: nunca se toma del token const { alg } = JSON.parse(decode(parts[0])); if (alg !== "HS256") return "✗ algoritmo"; const data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ firma"; const claims = JSON.parse(decode(parts[1])); if (typeof claims.exp !== "number" || now >= claims.exp) return "✗ expirado"; return "✓ " + claims.sub;}function same(a, b) { // compara todos los bytes, sin detenerse en la primera diferencia const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y);}- El algoritmo está fijado. El servidor sabe que firma con HS256 y no acepta nada más.
nonese detiene aquí. - La firma se compara en tiempo constante, como el token CSRF de la lección anterior.
expes obligatorio. Un token sinexpo con unexppasado se rechaza.
Los mismos cuatro tokens:
const now = 1_793_000_000;const good = issue("maria", now + 900);const p = good.split(".");const admin = encode({ sub: "admin", exp: now + 900 });// un payload modificado con la firma viejaconst changed = p[0] + "." + admin + "." + p[2];// alg: none y ninguna firmaconst none = encode({ alg: "none", typ: "JWT" }) + "." + admin + ".";const old = issue("maria", now - 60);console.log("auténtico:", verify(good, now));console.log("payload modificado:", verify(changed, now));console.log("alg: none:", verify(none, now));console.log("expirado:", verify(old, now));auténtico: ✓ mariapayload modificado: ✗ firmaalg: none: ✗ algoritmoexpirado: ✗ expiradoPruébalo
Completa el hueco
¿Qué algoritmo acepta la comprobación?
const { alg } = JSON.parse(decode(parts[0]));
if (alg !== "") return "✗ algoritmo";HS256: el algoritmo con el que firma el servidor. Está en el código del servidor, no en el token.
Pruébalo
Encuentra el error
Esta comprobación fija HS256 y compara bien la firma. Pero una versión vieja de la aplicación emitía tokens sin exp, y siguen funcionando hoy. ¿Qué línea tiene el error?
Elige la línea con el error.
La caducidad solo se comprueba si exp está. Un token sin exp pasa y es válido para siempre. Una caducidad que falta tiene que ser un rechazo: una comprobación de seguridad rechaza ante la duda.
La idea
Trampa 2: dónde vive el token y cómo se revoca
localStorage. Las aplicaciones web suelen guardar un JWT en localStorage, porque es cómodo. Pero todo script de la página lee localStorage. Un agujero XSS y el script ajeno envía el token al atacante, que lo usa hasta que caduca. La firma no ayuda: el token no está falsificado, está robado entero. En el navegador el lugar más seguro es una cookie con HttpOnly, pero entonces viaja sola y necesitas la defensa contra CSRF de la lección anterior.
Revocar. El servidor comprueba solo la firma y exp. Si roban el teléfono de María, su token funciona hasta exp, aunque cambie su contraseña. Por eso los tokens de acceso son cortos (de 5 a 15 minutos), y un inicio de sesión largo usa un refresh token que el servidor guarda y puede borrar. Otra forma es una lista de tokens revocados en el servidor, pero entonces el servidor vuelve a guardar estado, como con una sesión.
Cuándo una sesión es más simple. Si tienes un sitio web con un servidor, la cookie de sesión de la lección anterior es la respuesta más simple: la revocas borrando la fila, no hay firma que comprobar ni algoritmo que elegir mal. JWT tiene sentido cuando muchos servicios comprueban el token sin una tabla de sesiones común, o entre empresas distintas (iniciar sesión con Google es OpenID Connect, y eso es JWT).
Más precisamente, para curiosos
Las bibliotecas. En un proyecto real no escribes JWT a mano: Java tiene jjwt, java-jwt (Auth0) y Nimbus JOSE + JWT; C# tiene Microsoft.IdentityModel.JsonWebTokens, y ASP.NET Core comprueba tokens con AddJwtBearer; Python tiene PyJWT; Node tiene jsonwebtoken y jose. También con una biblioteca el algoritmo se fija de forma explícita: jwt.decode(token, key, algorithms=["HS256"]) en PyJWT, jwt.verify(token, key, { algorithms: ["HS256"] }) en jsonwebtoken, ValidAlgorithms en TokenValidationParameters, verifyWith(key) en jjwt. Las versiones viejas de algunas bibliotecas aceptaban none cuando no les decías el algoritmo.
RS256 y ES256. Con estos una clave privada firma y una clave pública comprueba: así muchos servicios pueden comprobar sin poder firmar. Entonces hay otra trampa, la confusión de algoritmo: un servidor que espera RS256 pero le pregunta al token puede aceptar HS256 con la clave pública como clave HMAC. El arreglo es el mismo: un algoritmo fijado.
Otros campos. iat (cuándo se emitió), nbf (no antes de), aud (para qué servicio), iss (quién lo emitió), jti (un id único, para revocar). Los relojes de los servidores se desfasan, por eso las bibliotecas permiten unos segundos de margen en exp.
Un JWT cifrado existe (JWE), pero rara vez hace falta: es más simple no poner secretos en el token.
Ejercicio
Cerrar sesión en todos los dispositivos
Resuélvelo en tu propio editor.
María pierde su teléfono y toca «Cerrar sesión en todos los dispositivos». Añade jti al payload, un id único y aleatorio del token. Escribe logout, que guarda el jti del token en un conjunto de revocados, y una comprobación que después de verify rechaza un token revocado con "✗ revocado". Un nuevo inicio de sesión da un token nuevo que funciona.
antes de cerrar sesión: ✓ mariadespués de cerrar sesión: ✗ revocadonuevo inicio de sesión: ✓ mariaMás tareas
- Una vida corta. Haz que el token de acceso dure 5 minutos y añade un refresh token guardado en el servidor en una tabla. ¿Qué tienes que borrar ahora en «Cerrar sesión en todos los dispositivos»?
- Limpieza. Un
jtirevocado solo hay que guardarlo hasta que el token caducaría de todos modos. Guarda tambiénexpy quita las entradas viejas.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Programación en red · Entrada no confiable · Contraseñas · Sesiones, cookies y CSRF
- Continúa en: Criptografía que no escribes tú · Escalado
- Ejercicios sobre el tema: Seguridad para programadores