Ir al contenido
wannacode

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}. sub es el usuario, exp es 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

def verify(token, now):
parts = token.split(".")
if len(parts) != 3:
return "✗ formato"
# el algoritmo está fijado: nunca se toma del token
if json.loads(decode(parts[0])).get("alg") != "HS256":
return "✗ algoritmo"
mac = sign(parts[0] + "." + parts[1]).encode()
if not hmac.compare_digest(mac, parts[2].encode()):
return "✗ firma"
claims = json.loads(decode(parts[1]))
exp = claims.get("exp")
if not isinstance(exp, int) or now >= exp:
return "✗ expirado"
return "✓ " + claims["sub"]
Paso a paso, en palabras
  1. El header dice cómo está firmado el token: HS256. El JSON se convierte en texto base64url.
  2. El payload dice quién y hasta cuándo: sub maria, exp en 15 minutos. También base64url.
  3. La firma es HMAC-SHA256 sobre "header.payload" con la clave del servidor. Sin la clave nadie puede hacerla.
  4. El token son las tres partes con puntos. La aplicación móvil lo envía en cada solicitud.
  5. Cualquiera que tenga el token lee el payload sin clave: base64url no es cifrado. Firmado, no secreto.
  6. Ahora un token falsificado: el header dice alg none, el payload dice sub admin, y no hay firma.
  7. La comprobación ingenua le pregunta al token qué algoritmo usar. "none" se salta la firma, y admin entra.
  8. El arreglo fija el algoritmo: solo HS256, decidido por el servidor. alg none se rechaza antes que nada.
  9. Un token auténtico pero viejo: la firma es correcta, pero exp ya pasó. La comprobación ingenua lo dejó entrar; esta no.
  10. 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»).

KEY = b"demo-key-only-for-this-lesson" # una clave de demostración; la real son 32 bytes aleatorios de un almacén de secretos
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def encode(obj):
return b64(json.dumps(obj, separators=(",", ":")).encode())
def sign(data):
return b64(hmac.new(KEY, data.encode(), hashlib.sha256).digest())
def issue(user, exp):
header = {"alg": "HS256", "typ": "JWT"}
payload = {"sub": user, "exp": exp}
data = encode(header) + "." + encode(payload)
return data + "." + sign(data)
json.dumps(…, separators=(",", ":")) escribe JSON sin espacios, como los otros lenguajes. base64.urlsafe_b64encode añade = al final, por eso rstrip(b"=") los quita.

Un token para María, válido 15 minutos:

now = 1_793_000_000
token = issue("maria", now + 900)
parts = token.split(".")
print("header: " + parts[0])
print("payload: " + parts[1])
print("firma: " + parts[2])
print("payload sin la clave:", decode(parts[1]))
Salida
header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0
firma: mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw
payload 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?

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw"
payload = token.split(".")[1]
print(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)).decode())
Respuesta

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:

def decode(part):
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)).decode()
# ASÍ NO: confía en el algoritmo del token
def naive_verify(token):
parts = token.split(".")
alg = json.loads(decode(parts[0]))["alg"]
if alg == "HS256":
if sign(parts[0] + "." + parts[1]) != parts[2]:
return "✗ firma"
# "none": el propio token dice que no tiene firma
return "✓ " + json.loads(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:

now = 1_793_000_000
good = issue("maria", now + 900)
p = good.split(".")
admin = encode({"sub": "admin", "exp": now + 900})
# un payload modificado con la firma vieja
changed = p[0] + "." + admin + "." + p[2]
# alg: none y ninguna firma
none = encode({"alg": "none", "typ": "JWT"}) + "." + admin + "."
old = issue("maria", now - 60)
print("auténtico:", naive_verify(good))
print("payload modificado:", naive_verify(changed))
print("alg: none:", naive_verify(none))
print("expirado:", naive_verify(old))
Salida
auténtico: ✓ maria
payload modificado: ✗ firma
alg: none: ✓ admin
expirado: ✓ maria

Un 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

def verify(token, now):
parts = token.split(".")
if len(parts) != 3:
return "✗ formato"
# el algoritmo está fijado: nunca se toma del token
if json.loads(decode(parts[0])).get("alg") != "HS256":
return "✗ algoritmo"
mac = sign(parts[0] + "." + parts[1]).encode()
if not hmac.compare_digest(mac, parts[2].encode()):
return "✗ firma"
claims = json.loads(decode(parts[1]))
exp = claims.get("exp")
if not isinstance(exp, int) or now >= exp:
return "✗ expirado"
return "✓ " + claims["sub"]
  1. El algoritmo está fijado. El servidor sabe que firma con HS256 y no acepta nada más. none se detiene aquí.
  2. La firma se compara en tiempo constante, como el token CSRF de la lección anterior.
  3. exp es obligatorio. Un token sin exp o con un exp pasado se rechaza.

Los mismos cuatro tokens:

now = 1_793_000_000
good = issue("maria", now + 900)
p = good.split(".")
admin = encode({"sub": "admin", "exp": now + 900})
# un payload modificado con la firma vieja
changed = p[0] + "." + admin + "." + p[2]
# alg: none y ninguna firma
none = encode({"alg": "none", "typ": "JWT"}) + "." + admin + "."
old = issue("maria", now - 60)
print("auténtico:", verify(good, now))
print("payload modificado:", verify(changed, now))
print("alg: none:", verify(none, now))
print("expirado:", verify(old, now))
Salida
auténtico: ✓ maria
payload modificado: ✗ firma
alg: none: ✗ algoritmo
expirado: ✗ expirado

Pruébalo

Completa el hueco

¿Qué algoritmo acepta la comprobación?

if json.loads(decode(parts[0])).get("alg") != "":
    return "✗ algoritmo"

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 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.

Salida
antes de cerrar sesión: ✓ maria
después de cerrar sesión: ✗ revocado
nuevo inicio de sesión: ✓ maria

Má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 jti revocado solo hay que guardarlo hasta que el token caducaría de todos modos. Guarda también exp y quita las entradas viejas.

Test

Ponte a prueba

1. ¿Quién puede leer el payload de un JWT?
2. ¿Por qué la comprobación fija el algoritmo (solo HS256) en lugar de leerlo del header?
3. Una aplicación web guarda el JWT en localStorage. ¿Qué pasa si la página tiene un agujero XSS?
4. Roban el teléfono de María, y el token de la aplicación vale 30 días. ¿Qué puede hacer la tienda?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·