JWT HS256 a mano: alg none, claves débiles, exp/aud
Arma y verifica un JWT HS256 con la librería estándar de Python, falsifica un token alg none, rompe una clave débil en 0,17 ms y corrige los tres fallos.
Serie: curso de seguridad básica 4/6 (intermedio). Para quien emite o valida JSON Web Tokens en una API, el backend de una app móvil o una SPA, casi siempre con una librería que nunca abrió. Al terminar vas a haber armado y verificado un JWT HS256 sin nada más que la librería estándar, y vas a conocer los tres errores que convierten un JWT en una puerta abierta.
Si saliste de un bootcamp de Node o Django, es probable que tu primer login con JWT lo hayas copiado de un tutorial con secret: "secreto" en el código. Como freelance, ese mismo patrón termina en producción en la app de un cliente. Los JWT fallan de maneras aburridas: claves débiles, tokens que debían rechazarse y se aceptan, y un claim role que cualquiera puede editar. Bajo el RGPD o las leyes de datos personales latinoamericanas, que alguien entre a cuentas ajenas por un error así difícilmente se considera una "medida de seguridad adecuada" (idea general, no asesoría legal). En vez de describir estos errores, escribimos un verificador pequeño, lo rompemos a propósito, medimos la ruptura y lo arreglamos. Todo corrió en Python 3.9.6, PHP 8.5.6 y OpenSSL 4.1.0 en una Mac con Apple Silicon (M1 Pro). No instalamos ninguna librería de JWT, y esa es la idea. Cada ataque corre solo contra tokens y claves que creamos localmente.
Te va a servir la idea de MAC del curso 1 y las claves aleatorias del curso 3.
Un JWT son tres cadenas base64url y un HMAC
Un JWT HS256 tiene tres partes separadas por puntos:
- Header: JSON como
{"alg":"HS256","typ":"JWT"}. - Payload: JSON con "claims" como
sub(el sujeto, o sea el usuario),exp(vencimiento, en segundos Unix),aud(audiencia, para quién es el token). - Firma: HMAC-SHA256 de los bytes ASCII
base64url(header) + "." + base64url(payload), con una clave secreta compartida.
base64url es base64 con - y _ en lugar de + y /, y sin el relleno =. Todo entra en 15 líneas:
# jwt_min.py - JWT HS256 a mano, solo con la librería estándar de Python
import base64, hashlib, hmac, json
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def sign_hs256(payload: dict, key: bytes) -> str:
header = {"alg": "HS256", "typ": "JWT"}
h = b64url(json.dumps(header, separators=(",", ":")).encode())
p = b64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode())
sig = hmac.new(key, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
return f"{h}.{p}.{b64url(sig)}"
if __name__ == "__main__":
KEY = b"demo-key-for-this-post-only-0001" # 32 bytes, NO es un secreto real
tok = sign_hs256({"sub": "user-42", "name": "Lucía", "role": "user",
"iat": 1794614400, "exp": 1794618000}, KEY)
print(tok)
for part in tok.split(".")[:2]:
print(b64url_decode(part).decode())
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6Ikx1Y8OtYSIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzk0NjE0NDAwLCJleHAiOjE3OTQ2MTgwMDB9.2oC-CsURBjxkGdg20qtmhrAB_e2iMar_ftl09NTvEjg
{"alg":"HS256","typ":"JWT"}
{"sub":"user-42","name":"Lucía","role":"user","iat":1794614400,"exp":1794618000}
Fíjate en lo que acaba de pasar: el segundo bucle imprimió el payload sin la clave, con la tilde de "Lucía" intacta. Un JWT firmado no está cifrado. Cualquiera que vea el token (las devtools del navegador, el log de un proxy, un reporte de errores) puede leer todos los claims. Si vas a poner un email o un número de documento, pregúntate primero si está bien que lo vea cualquiera.
Para comprobar que el formato es estándar, verificamos un token hecho igual con PHP y con la CLI de OpenSSL. Ambos dieron la misma firma (abajo, el ejemplo con el token de la versión en inglés, cuyo name es Alex):
<?php
function b64url_decode(string $s): string { return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4)); }
[$h, $p, $s] = explode('.', $argv[1]);
$key = 'demo-key-for-this-post-only-0001';
$hdr = json_decode(b64url_decode($h), true, 8, JSON_THROW_ON_ERROR);
if (($hdr['alg'] ?? null) !== 'HS256') exit("alg not allowed\n");
$good = hash_hmac('sha256', "$h.$p", $key, true);
echo hash_equals($good, b64url_decode($s)) ? "PHP: signature OK\n" : "PHP: bad signature\n";
$ php verify.php "$TOKEN"
PHP: signature OK
$ printf %s "$(echo $TOKEN | cut -d. -f1-2)" | openssl dgst -sha256 -hmac demo-key-for-this-post-only-0001 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='
tYECXfS5xKO3rsp1e3ArWetCHPiygT8spAmzNzjhBfc
Aquí se esconde una regla sutil: verifica sobre los bytes que recibiste, nunca sobre un JSON que volviste a serializar. Si tu verificador parsea el payload y lo vuelve a generar, un orden de claves o un espacio distinto (", " vs ",") cambia los bytes y todas las firmas fallan. Con español pasa algo más: según ensure_ascii, "Lucía" se escribe "Lucía" o "Lucía", y son bytes distintos.
Error común: dejar que el token elija el alg → admin falso → solución
Muchas librerías de JWT de las primeras épocas dejaban que el token eligiera su propio algoritmo, porque el header dice cuál se usó. Y el RFC 7518 hasta define "alg": "none" para JWT "sin protección". Junta las dos cosas y obtienes este verificador, que escribimos mal a propósito:
# jwt_naive.py - un verificador MALO a propósito (demo local). Guárdalo junto a jwt_min.py
import hashlib, hmac, json, time
from jwt_min import b64url, b64url_decode, sign_hs256
KEY = b"demo-key-for-this-post-only-0001"
def verify_naive(token: str, key: bytes) -> dict:
h, p, s = token.split(".")
header = json.loads(b64url_decode(h))
if header["alg"] == "HS256":
good = b64url(hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest())
if s != good:
raise ValueError("bad signature")
elif header["alg"].lower() == "none":
pass # "token sin firma, no hay nada que revisar"
else:
raise ValueError("unsupported alg")
return json.loads(b64url_decode(p))
T = 1794614400
good = sign_hs256({"sub": "user-42", "name": "Lucía", "role": "user",
"aud": "api.example.com", "iat": T, "exp": T + 900}, KEY)
print("naive, real token :", verify_naive(good, KEY)["role"])
h, p, s = good.split(".")
claims = json.loads(b64url_decode(p)); claims["role"] = "admin"
p2 = b64url(json.dumps(claims, separators=(",", ":"), ensure_ascii=False).encode())
try:
verify_naive(f"{h}.{p2}.{s}", KEY)
except ValueError as e:
print("naive, edited role:", e)
forged = b64url(b'{"alg":"none","typ":"JWT"}') + "." + p2 + "." # firma vacía
print("naive, alg=none :", verify_naive(forged, KEY)["role"])
naive, real token : user
naive, edited role: bad signature
naive, alg=none : admin
Editar solo el payload se detectó: el HMAC ya no coincidía. Pero cambia el header a none, quita la firma, y el verificador devuelve role: admin. No hizo falta la clave. El .lower() lo empeora: None, NONE y nOnE también pasan.
Solución: el servidor decide el algoritmo, desde su propia configuración, antes de mirar cualquier otra cosa. Compara la firma en tiempo constante y después revisa los claims. Es el orden de la figura de abajo.
def verify(token: str, key: bytes, *, audience: str, leeway: int = 30, now=None) -> dict:
try:
h, p, s = token.split(".")
except ValueError:
raise ValueError("malformed token")
header = json.loads(b64url_decode(h))
if header.get("alg") != "HS256": # lista permitida, la decide el SERVIDOR
raise ValueError(f"alg not allowed: {header.get('alg')!r}")
good = hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(b64url_decode(s), good):
raise ValueError("bad signature")
claims = json.loads(b64url_decode(p))
now = time.time() if now is None else now
if "exp" not in claims or now > claims["exp"] + leeway:
raise ValueError("expired")
if "nbf" in claims and now < claims["nbf"] - leeway:
raise ValueError("not yet valid")
aud = claims.get("aud")
if audience not in (aud if isinstance(aud, list) else [aud]):
raise ValueError(f"wrong audience: {aud!r}")
return claims
# agrégalo al final de jwt_naive.py, con los mismos tokens de arriba
for t, label in ((forged, "alg=none"), (f"{h}.{p2}.{s}", "edited role"), (good, "real token")):
try: print("fixed,", label.ljust(11), ":", verify(t, KEY, audience="api.example.com", now=T + 60)["role"])
except ValueError as e: print("fixed,", label.ljust(11), ":", e)
for when, aud in ((T + 2000, "api.example.com"), (T + 60, "admin.example.com")):
try: verify(good, KEY, audience=aud, now=when)
except ValueError as e: print("fixed, now=T+%-5d aud=%s: %s" % (when - T, aud, e))
fixed, alg=none : alg not allowed: 'none'
fixed, edited role : bad signature
fixed, real token : user
fixed, now=T+2000 aud=api.example.com: expired
fixed, now=T+60 aud=admin.example.com: wrong audience: 'api.example.com'
¿Por qué compare_digest y no !=? Una comparación común puede cortar en el primer byte distinto, y eso puede filtrar información por el tiempo. Si esa filtración se puede medir en Python es justo lo que prueba el curso 6. Usa la función de tiempo constante igual; no cuesta nada.
Regla: nunca dejes que el token elija cómo se verifica. En una librería real eso significa pasar algorithms=["HS256"] (o su equivalente) de forma explícita, siempre.
Claves débiles: rotas en 0,17 ms, medido
HS256 es tan fuerte como su clave. Si la clave es una palabra, cualquiera que tenga un token válido puede probar candidatos offline, sin rate limiting y sin dejar logs en tu servidor: calcula el HMAC con cada candidata y lo compara con la firma. Lo probamos contra nuestro propio token con una lista inventada de 20 palabras, cada una en 5 variantes (tal cual, con mayúscula inicial, +123, +2026, mayúscula inicial + !), 100 candidatas en total. La clave de esta versión es Secreto!, el tipo de valor que aparece en tutoriales y en archivos .env de ejemplo que nadie cambia.
# jwt_crack.py - demo LOCAL: una clave HS256 débil vs una lista inventada mínima
import hashlib, hmac, secrets, time
from jwt_min import sign_hs256, b64url_decode
WORDS = ["secret", "password", "changeme", "jwtsecret", "mysecret", "admin", "letmein",
"qwerty", "dragon", "sunshine", "himitsu", "sakura", "saranghae", "hangang",
"secreto", "contrasena", "futbol", "tequila", "dev", "test"] # 20 palabras inventadas
def candidates():
for w in WORDS:
for v in (w, w.capitalize(), w + "123", w + "2026", w.capitalize() + "!"):
yield v.encode()
token = sign_hs256({"sub": "user-42", "role": "user", "exp": 1794618000}, b"Secreto!")
h, p, s = token.split(".")
msg, target = f"{h}.{p}".encode(), b64url_decode(s)
t0 = time.perf_counter_ns(); tried = 0
for key in candidates():
tried += 1
if hmac.compare_digest(hmac.new(key, msg, hashlib.sha256).digest(), target):
print(f"found {key.decode()!r} after {tried} of 100 candidates "
f"in {(time.perf_counter_ns() - t0) / 1e6:.3f} ms")
break
rates = [] # velocidad bruta de HMAC: 200.000 claves aleatorias, 5 corridas
keys = [secrets.token_bytes(12) for _ in range(200_000)]
for _ in range(5):
t0 = time.perf_counter_ns()
for k in keys: hmac.new(k, msg, hashlib.sha256).digest()
rates.append(len(keys) / ((time.perf_counter_ns() - t0) / 1e9))
rates.sort()
print(f"HMAC-SHA256 guesses/s (median of 5): {rates[2]:,.0f} runs={[round(x) for x in rates]}")
found 'Secreto!' after 75 of 100 candidates in 0.172 ms
HMAC-SHA256 guesses/s (median of 5): 545,913 runs=[545153, 545549, 545913, 545990, 547003]
Python puro, en un solo hilo, hizo unos 546.000 intentos por segundo. Las herramientas dedicadas con GPU son mucho más rápidas; no corrimos ninguna, así que esa columna de la tabla usa un supuesto de 10¹⁰/s.
| Clave | Espacio | Tiempo esperado a 545.913/s medidos | A 10¹⁰/s supuestos |
|---|---|---|---|
| una palabra de diccionario (+ variantes) | ~100 a millones | milisegundos a segundos | instantáneo |
| 8 minúsculas aleatorias | 26⁸ | 2,2 días | 10 s |
| 12 letras y dígitos aleatorios | 62¹² | ~94 millones de años | ~5.100 años |
secrets.token_bytes(32) |
2²⁵⁶ | ~3,4 × 10⁶³ años | ~1,8 × 10⁵⁹ años |
Solución: genera la clave una sola vez con un CSPRNG y déjala fuera del código.
$ python3 -c "import secrets; print(secrets.token_urlsafe(32))"
$ openssl rand -base64 32
Guárdala en una variable de entorno o un gestor de secretos, no en Git. Pon un header kid (ID de clave) para poder rotarlas: verifica con la clave que nombra kid, pero solo dentro de tu propio conjunto fijo de claves.
Regla: una clave HS256 es una clave, no una contraseña. 32 bytes aleatorios, nunca una palabra.
exp, aud, nbf y el desfase de relojes
La firma solo prueba que el token lo hizo alguien con la clave. Los claims deciden si se acepta ahora y aquí:
exp(vencimiento): rechazar después. También rechazamos tokens sinexp; un token sin vencimiento es una contraseña permanente. Los access tokens, cortos (5 a 15 minutos).nbf(no antes de) eiat(emitido en): sirven para detectar tokens "del futuro".aud(audiencia): un token emitido paraapi.example.comno debe abriradmin.example.com, aunque compartan clave. La salida de arriba muestra el rechazo.iss(emisor): revísalo cuando varios servicios pueden emitir tokens.- Margen (leeway): los relojes de los servidores difieren unos segundos. Permitimos 30; valores mayores alargan en silencio todos los tokens.
Dónde guardar el token (descrito, no ejecutado)
Muchos incidentes reales pasan por el almacenamiento, así que aquí van los trade-offs. No corrimos un navegador para este artículo.
| Lugar | ¿XSS lo puede leer? | ¿Se envía solo (riesgo CSRF)? | Notas |
|---|---|---|---|
localStorage |
sí | no | un solo bug de XSS filtra todos los tokens |
Cookie con HttpOnly; Secure; SameSite=Lax |
no | sí, limitado por SameSite | combínalo con defensas CSRF; mira cookies y sesiones |
| Solo en memoria (variable JS) | solo mientras corre la página | no | se pierde al recargar; usa un refresh token en una cookie HttpOnly |
| Móvil: Keychain de iOS / Keystore de Android | no aplica | no | no SharedPreferences ni archivos de texto plano |
Y la pregunta que va primero: ¿de verdad necesitas un JWT? Un JWT no se puede revocar antes de exp sin una lista en el servidor, que es otra vez un almacén de sesiones. Para una sola app web hablando con su propio backend, un ID de sesión aleatorio en una cookie HttpOnly (curso 3) es más simple y revocable. Los JWT valen la pena cuando servicios separados deben validar un token sin consultar un almacén central. El lado de las sesiones lo cubre el artículo de seguridad de sesiones en PHP (en inglés).
Otras trampas que conviene conocer, solo descritas: confusión de algoritmo (un servidor que acepta RS256 y HS256 puede verificar un token HS256 usando la clave pública RSA como secreto HMAC; fija un algoritmo por clave), inyección por kid (un kid usado como ruta de archivo o valor SQL; mira inyección SQL con PDO (en inglés)), headers jku/x5u que apuntan a claves alojadas por el atacante, y las funciones "decode" vs "verify" de las librerías, donde decode se salta la firma.
Límites honestos y notas de versión
- JWT es el RFC 7519 (2015); la forma firmada es JWS, RFC 7515;
"none"se define en el RFC 7518. El RFC 8725 (2020) reúne las buenas prácticas actuales, incluida la lista permitida de algoritmos. No corrimos ninguna librería de JWT (PyJWT, firebase/php-jwt, jose), así que no afirmamos nada sobre sus valores por defecto en una versión dada. Busca en la documentación de tu librería el parámetro que fija el algoritmo. - Nuestro código solo soporta HS256 y la serialización compacta, y no revisa
iss,typnicrit. Es material de estudio, no algo para poner en producción. - Los 545.913 intentos/s son de CPython 3.9.6 en un solo hilo. La columna de 10¹⁰/s es un supuesto.
Ejercicios
- El verificador de tu compañero primero decodifica el payload, revisa
expy después revisa la firma. Con tokens válidos el resultado es el mismo. ¿Hay algún problema? - Escribe un cambio de una línea en
sign_hs256que agregue"kid": "2026-11"al header. ¿Qué parte de tu verificador tiene que cambiar, y cómo, para que no se pueda abusar dekid?
Respuestas
- Funciona, pero parsea JSON controlado por el atacante y corre lógica sobre él antes de saber si el token es auténtico. Cualquier bug en ese código (un error con un número enorme, una línea de log con los claims) queda al alcance sin clave. Verifica primero la firma; después confía en los claims y revísalos.
header = {"alg": "HS256", "typ": "JWT", "kid": "2026-11"}. El verificador busca la clave en un diccionario fijo, por ejemploKEYS = {"2026-11": ..., "2026-05": ...}, y rechaza loskiddesconocidos. Nunca debe usarkidcomo ruta, URL o valor SQL.
Siguiente
- Anterior: Seguridad básica 3/6: tokens aleatorios seguros con secrets
- Siguiente: Seguridad básica 5/6: cifrado simétrico AES-GCM bien hecho, para cuando el payload de verdad tiene que ser secreto
- Volver a la hoja de ruta para aprender programación, y siempre sobre HTTPS: HTTPS, TLS y certificados (en inglés)
Preguntas frecuentes
¿El payload de un JWT está cifrado?
No. Un JWT firmado (JWS) es JSON en base64url más una firma. Cualquiera que tenga el token puede decodificar el payload; nosotros lo imprimimos, con el nombre Lucía incluido, usando base64.urlsafe_b64decode y sin clave. La firma solo prueba quién lo hizo y que no se modificó. Nunca pongas ahí datos que el usuario no debería ver.
¿Qué es la vulnerabilidad alg none en JWT?
Un verificador que lee el algoritmo del propio header del token puede recibir alg none, es decir, sin firma. Nuestro verificador ingenuo aceptó un token falso con la firma vacía y el role cambiado a admin. La solución es fijar el algoritmo en el servidor (una lista permitida con exactamente HS256) y rechazar todo lo demás antes de mirar la firma.
¿Qué tan larga debe ser una clave HS256?
Usa 32 bytes aleatorios de un CSPRNG, por ejemplo secrets.token_bytes(32). Una clave basada en una palabra cayó en 0,17 ms con una lista de 100 candidatos en nuestra prueba. A los 545.913 intentos por segundo que medimos, 32 bytes aleatorios tomarían en promedio unos 3,4 × 10^63 años.