En 01-02 aprendiste a reconocer el prefijo eyJ como la huella de un JSON en Base64, y descubrimos que el token de sesión del portal de MediNube era exactamente eso: un JSON codificado, sin firma, que cualquiera podía editar. Prometimos volver con las herramientas necesarias para arreglarlo, y ese día es hoy: tenemos HMAC (03-02), firmas asimétricas (04-03) y un gestor de claves (06-01). En esta lección salda MediNube su deuda más antigua: demostramos el ataque contra el token heredado, entendemos qué es un JWT de verdad, aprendemos a usarlo con PyJWT esquivando sus trampas clásicas (alg: none, confusión de algoritmos, claims sin validar) y construimos medinube/sesiones.py, el módulo de sesiones definitivo del portal.
Contenido
- La deuda de 01-02: el token que cualquiera podía editar
- Sesiones con estado vs tokens autocontenidos
- Anatomía de un JWT: header, payload, signature
- Firmar y verificar con PyJWT: HS256 y EdDSA
- Los ataques clásicos contra JWT y su defensa
- Expiración corta y refresh tokens
- Revocación: el precio de lo autocontenido
- Dónde guardar el token en el navegador
medinube/sesiones.py: la implementación final
La deuda de 01-02: el token que cualquiera podía editar
Este es el código heredado que el portal de MediNube ha estado usando para las sesiones — nuestro _MAL más antiguo:
# medinube/sesiones_legacy.py — NO USAR
import base64, json
def crear_sesion_MAL(usuario: str, rol: str) -> str:
datos = {"usuario": usuario, "rol": rol}
return base64.urlsafe_b64encode(json.dumps(datos).encode()).decode()
def leer_sesion_MAL(token: str) -> dict:
return json.loads(base64.urlsafe_b64decode(token))El ataque no requiere ni criptoanálisis ni herramientas: Ana Pérez (o MalloryClinic con la cuenta de Ana) abre la consola del navegador y hace esto:
>>> token = crear_sesion_MAL("ana.perez", "paciente")
>>> token
'eyJ1c3VhcmlvIjogImFuYS5wZXJleiIsICJyb2wiOiAicGFjaWVudGUifQ=='
# El "atacante" decodifica, edita y recodifica:
>>> datos = json.loads(base64.urlsafe_b64decode(token))
>>> datos["rol"] = "medico" # ← ascenso instantáneo
>>> falso = base64.urlsafe_b64encode(json.dumps(datos).encode()).decode()
>>> leer_sesion_MAL(falso)
{'usuario': 'ana.perez', 'rol': 'medico'} # el servidor se lo creeRegla de oro 4 en su versión más cara: codificar no es cifrar... y tampoco es autenticar. Base64 es un cambio de alfabeto, no una protección. El servidor está confiando en un dato que fabricó el cliente. La solución no es ocultar el contenido (el usuario puede saber su propio rol), es que el servidor pueda verificar que él lo emitió y nadie lo tocó: una firma. Eso es, exactamente, un JWT.
Sesiones con estado vs tokens autocontenidos
Antes de correr hacia JWT, honestidad de ingeniero: la alternativa clásica sigue siendo excelente. Una sesión con estado guarda los datos en el servidor y le da al navegador solo un identificador aleatorio opaco (cookie con 128+ bits del CSPRNG, regla 3 y 5). Un token autocontenido (JWT) lleva los datos consigo, firmados.
| Aspecto | Sesión con estado (cookie + tabla) | Token autocontenido (JWT) |
|---|---|---|
| El servidor guarda | Una fila por sesión | Nada (la clave de firma) |
| Revocación | Trivial: DELETE FROM sesiones |
Difícil (lo veremos: jti + lista) |
| Verificación | Consulta a BD/Redis por petición | Solo criptografía, sin I/O |
| Escenario ideal | Aplicación monolítica con su BD al lado | Microservicios, APIs, terceros que verifican |
| Contenido visible | No (el id es opaco) | Sí: el payload es legible por diseño |
| Caducidad | La decide el servidor en vivo | Grabada en el token al emitirlo |
La tabla dice algo incómodo para el hype: si tu aplicación es un monolito con una BD a un milisegundo, la cookie de sesión clásica es más simple y más segura (revocación instantánea). JWT gana cuando la verificación debe ocurrir lejos del emisor: entre los microservicios de MediNube sin tocar la BD en cada salto, o cuando la Farmacia Robles debe verificar un token emitido por MediNube sin llamarnos. Ese es nuestro caso de uso real, así que adelante.
Anatomía de un JWT: header, payload, signature
Un JWT (JSON Web Token, RFC 7519) son tres bloques Base64URL separados por puntos: header.payload.signature. Como header y payload son JSON, un JWT auténtico enseña... ¡dos o tres eyJ! Decodifiquemos uno a mano para perderle todo el misterio:
import base64, json
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiJhbmEucGVyZXoiLCJyb2wiOiJwYWNpZW50ZSIsImlzcyI6Imh0dHBzOi8v"
"YXBpLm1lZGludWJlLmV4YW1wbGUiLCJleHAiOjE3ODM1MDQwMDB9."
"4v0X9K1v2xLQnO7Yd3rZ8mPqW5tJc6hB0aFgN_sUwEo")
def b64url_decode(parte: str) -> bytes:
# Base64URL omite el padding '='; hay que reponerlo para decodificar
return base64.urlsafe_b64decode(parte + "=" * (-len(parte) % 4))
header, payload, firma = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'ana.perez', 'rol': 'paciente', ...}
# firma: bytes crudos, NO es JSON — es HMAC-SHA256(clave, header + "." + payload)Observa: cualquiera puede leer el header y el payload (solo es Base64URL). La firma no oculta nada, garantiza integridad y origen — como el tag de GCM en 02-03 o la firma de las recetas en 04-03. Nunca metas secretos en un payload JWT.
Los claims estándar que usaremos siempre:
| Claim | Significado | Por qué importa |
|---|---|---|
sub |
Subject: de quién habla el token (ana.perez) |
La identidad |
exp |
Expiration: caducidad (timestamp Unix) | Sin él, un token robado vale para siempre |
iat |
Issued at: momento de emisión | Auditoría; permite invalidar "todo lo anterior a" |
iss |
Issuer: quién lo emitió (https://api.medinube.example) |
Evita aceptar tokens de otro emisor |
aud |
Audience: para quién es (farmacia-robles) |
Evita reutilizar un token de un servicio en otro |
jti |
JWT id: identificador único del token | La pieza que hará posible la revocación |
Firmar y verificar con PyJWT: HS256 y EdDSA
PyJWT (pip install pyjwt[crypto]) hace el trabajo. La primera decisión es el algoritmo, y ya conoces a los dos candidatos:
- HS256 = HMAC-SHA256 (03-02): simétrico. Quien puede verificar puede también emitir. Perfecto entre servicios internos de MediNube que comparten la clave (del gestor de 06-01).
- RS256 (RSA-PSS no, ojo: RS256 es PKCS#1 v1.5; PS256 es PSS) y EdDSA (Ed25519, 04-03): asimétricos. Se firma con la privada y se verifica con la pública. Imprescindible cuando verifican terceros: la Farmacia Robles verifica con nuestra clave pública sin poder emitir tokens — con HS256 tendríamos que darle la clave y podría fabricar tokens de MediNube.
import jwt, time
# --- Interno (HS256): entre servicios de MediNube ---
CLAVE_HS = obtener_del_gestor("medinube/produccion/jwt-interno") # 32 bytes CSPRNG
token = jwt.encode(
{"sub": "servicio-recetas", "iss": "https://api.medinube.example",
"aud": "servicio-historiales", "iat": int(time.time()),
"exp": int(time.time()) + 300}, # 5 minutos: interno = corto
CLAVE_HS, algorithm="HS256",
)
# --- Externo (EdDSA): la Farmacia Robles verifica sin poder emitir ---
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
privada = cargar_privada_del_gestor("medinube/produccion/jwt-firma-ed25519")
token_farmacia = jwt.encode(
{"sub": "receta-8842", "iss": "https://api.medinube.example",
"aud": "farmacia-robles", "exp": int(time.time()) + 900},
privada, algorithm="EdDSA",
)
# Farmacia Robles solo necesita la clave PÚBLICA para verificar:
datos = jwt.decode(token_farmacia, publica_medinube, algorithms=["EdDSA"],
audience="farmacia-robles", issuer="https://api.medinube.example")Explicación de las decisiones: las claves salen del gestor (06-01), nunca del código; el exp interno es de minutos (los tokens internos se piden y se tiran); y elegimos EdDSA sobre RS256 por las mismas razones que en 04-03 — claves y firmas pequeñas, sin decisiones de padding que estropear, rendimiento excelente.
Los ataques clásicos contra JWT y su defensa
La historia de JWT está llena de implementaciones rotas. Los tres ataques que debes conocer atacan un mismo punto débil: dejar que el token decida cómo se verifica a sí mismo.
alg: none: la especificación original permitía{"alg": "none"}= "sin firma". Las librerías antiguas aceptaban ese header... escrito por el atacante: fabricabas un token con el payload que quisieras,alg: none, firma vacía, y dentro. Escrear_sesion_MALcon disfraz de estándar.- Confusión RS256 → HS256: el atacante coge un token RS256 legítimo, cambia el header a HS256 y firma con HMAC usando... la clave pública (que es pública) como clave HMAC. Una librería ingenua que reciba "la clave" como un blob y obedezca el
algdel token verificará el HMAC con la pública ¡y pasará! La pública era para verificar RSA, no para ser clave simétrica secreta. - Claims sin validar: firmar bien y luego no comprobar
exp(tokens eternos),aud(un token emitido para la Farmacia Robles reutilizado contra la API de historiales) oiss(aceptar tokens de otro emisor con clave conocida).
La defensa con PyJWT moderno es una sola disciplina: el verificador fija sus expectativas; el token no opina.
def verificar_MAL(token):
# NUNCA: sin algorithms= (PyJWT moderno lo rechaza, versiones viejas no),
# sin audience, sin issuer... el token manda.
return jwt.decode(token, CLAVE, options={"verify_signature": False}) # ← horror
def verificar_bien(token: str, publica) -> dict:
return jwt.decode(
token,
publica,
algorithms=["EdDSA"], # lista CERRADA: mata 'none' y la confusión
audience="farmacia-robles", # este token es PARA mí
issuer="https://api.medinube.example", # y lo emitió quien espero
options={"require": ["exp", "iss", "aud", "sub"]}, # claims obligatorios
leeway=30, # 30 s de tolerancia de reloj
)Punto por punto: algorithms=["EdDSA"] es obligatorio siempre — al ser una lista cerrada elegida por ti, alg: none y el cambio a HS256 mueren aquí (PyJWT además se niega a usar una clave pública asimétrica con algoritmos HMAC, defensa en profundidad); audience e issuer cierran la reutilización entre servicios; require convierte la ausencia de un claim en error en vez de en "no se comprueba"; leeway evita falsos rechazos por relojes ligeramente desincronizados. Cualquier fallo lanza una excepción (jwt.InvalidSignatureError, jwt.ExpiredSignatureError...): captúralas y devuelve 401, sin filtrar detalles al cliente.
Expiración corta y refresh tokens
Si un JWT robado es válido hasta su exp, la primera mitigación es obvia: exp corto (minutos). Pero nadie quiere reloguearse cada 15 minutos. La solución estándar es la pareja access + refresh:
sequenceDiagram
participant N as Navegador (Ana)
participant A as api.medinube.example
participant BD as BD (tabla refresh)
N->>A: login (usuario + contraseña → Argon2id, 03-03)
A->>BD: guarda sha256(refresh)
A-->>N: access JWT (15 min) + refresh token (7 días)
Note over N,A: ...pasan 15 minutos, el access caduca...
N->>A: POST /token/refresh (refresh token)
A->>BD: ¿existe sha256(refresh) y no está usado/revocado?
A->>BD: marca el viejo como usado, guarda sha256(nuevo)
A-->>N: access nuevo + refresh NUEVO (rotación)
Las piezas del diseño, todas conocidas:
- El access token es el JWT: autocontenido, verificable sin BD, vida de 15 minutos.
- El refresh token no necesita ser un JWT: es un secreto opaco del CSPRNG (
secrets.token_urlsafe(32)) que sí vive en la BD... almacenado comosha256(token), exactamente el patrón de los tokens de recuperación de 03-03: un volcado de la BD no regala sesiones. - Rotación con detección de robo: cada uso del refresh emite uno nuevo e invalida el anterior. Si alguien presenta un refresh ya usado, hay dos poseedores → se revoca la familia entera y Ana tiene que hacer login de nuevo.
Revocación: el precio de lo autocontenido
"Cerrar sesión en todos los dispositivos", "este empleado ya no trabaja aquí", "se filtró un token": con sesiones de BD es un DELETE; con JWT, el token firmado sigue siendo criptográficamente válido hasta su exp. No hay firma que "des-firmar". Opciones, de menos a más estado:
- TTLs cortos como mitigación principal: con access de 15 minutos, la ventana de un token robado es de 15 minutos, y la revocación real ocurre en el refresh (que sí está en BD). Es la moraleja de las vidas cortas de los certificados de 05-03, calcada: cuando revocar es difícil, caduca rápido.
- Lista de revocados por
jti: una tabla/Redis con losjtirevocados hasta suexp. El verificador consulta la lista... y acabas de reintroducir una consulta por petición — el híbrido honesto: solo la consultan los endpoints sensibles. - Invalidación por usuario: guardar por usuario un
no_valido_antes_de; todo token coniatanterior muere. Barato (un valor por usuario, cacheable) y cubre "cerrar todas mis sesiones".
MediNube usa 1 + 3: access de 15 minutos y marca por usuario para el botón del pánico.
Mención rápida para completar el mapa: además de JWS (firmado, lo que hemos hecho), existe JWE (JSON Web Encryption): el token va cifrado, no solo firmado, para payloads que el cliente no debe leer. Cinco partes en vez de tres, misma familia de estándares (JOSE). MediNube no lo necesita — no ponemos nada secreto en el payload — pero debes reconocerlo cuando lo veas.
Dónde guardar el token en el navegador
Breve y práctico, porque aquí se pierden muchas batallas ganadas por la criptografía:
localStorage: accesible desde JavaScript → cualquier XSS exfiltra el token. Evítalo para tokens de sesión.- Cookie con
HttpOnly; Secure; SameSite=Lax(oStrict): JavaScript no puede leerla (HttpOnlyneutraliza el robo por XSS), solo viaja por HTTPS (Secure, con el HSTS de 05-02 detrás) ySameSitecorta la mayor parte del CSRF. Es la opción por defecto correcta para el portal de MediNube.
El JWT dentro de una cookie HttpOnly combina lo mejor de ambos mundos: verificación sin estado en el servidor, robo por XSS bloqueado en el cliente. Regla de oro 9: la criptografía no sustituye al resto de la seguridad — un token perfectamente firmado en localStorage sigue siendo un token robado con un XSS.
medinube/sesiones.py: la implementación final
Todo junto: EdDSA, claims completos, expectativas cerradas y kid (key id) en el header para poder rotar la clave de firma sin invalidar las sesiones vivas — la misma jugada que el byte 0x01/0x02 de los historiales, ahora en JWT:
# medinube/sesiones.py — sesiones del portal, versión definitiva
import secrets, time, jwt
ISS = "https://portal.medinube.example"
AUD = "portal"
TTL_ACCESS = 15 * 60
# Claves de firma versionadas por kid, servidas por el gestor (06-01).
# "2026-07" firma; "2026-01" solo verifica hasta que muera su último token.
CLAVES = {
"2026-07": cargar_par_ed25519("medinube/produccion/jwt-portal/2026-07"),
"2026-01": cargar_par_ed25519("medinube/produccion/jwt-portal/2026-01"),
}
KID_ACTIVO = "2026-07"
def emitir(usuario: str, rol: str) -> str:
ahora = int(time.time())
return jwt.encode(
{"sub": usuario, "rol": rol, "iss": ISS, "aud": AUD,
"iat": ahora, "exp": ahora + TTL_ACCESS, "jti": secrets.token_hex(16)},
CLAVES[KID_ACTIVO].privada,
algorithm="EdDSA",
headers={"kid": KID_ACTIVO}, # ← qué clave verifica este token
)
def verificar(token: str) -> dict:
kid = jwt.get_unverified_header(token).get("kid")
if kid not in CLAVES: # kid desconocido = token rechazado
raise jwt.InvalidTokenError("kid desconocido")
datos = jwt.decode(
token, CLAVES[kid].publica,
algorithms=["EdDSA"], audience=AUD, issuer=ISS,
options={"require": ["exp", "iat", "sub", "jti"]}, leeway=30,
)
if datos["iat"] < no_valido_antes_de(datos["sub"]): # botón del pánico
raise jwt.InvalidTokenError("sesiones del usuario invalidadas")
return datosEl único dato que se lee del token antes de verificar es el kid, y solo para elegir entre claves que nosotros ya confiamos (si no está en CLAVES, fuera): el token nunca aporta material de verificación, solo señala cuál de los nuestros usar. Compara este módulo con crear_sesion_MAL: mismo problema, dos módulos de curso de distancia.
Errores Comunes y Consejos
- Error: creer que el JWT está cifrado porque "no se lee". Es Base64URL: se lee con dos líneas de Python. Nada secreto en el payload; para eso existe JWE.
- Error:
jwt.decode(token, clave)sinalgorithms=[...]. En PyJWT moderno falla, pero en librerías/versiones antiguas es la puerta dealg: noney de la confusión RS256→HS256. Escribe la lista cerrada siempre, en todos los lenguajes. - Error: usar HS256 con terceros. Darle la clave de verificación a la Farmacia Robles es darle la de emisión. Terceros = asimétrico (EdDSA/RS256), pública para ellos, privada en el gestor.
- Error: access tokens de horas o días "para no molestar al usuario". Para eso está el refresh token; el access largo convierte cada robo en una sesión persistente sin revocación.
- Error: guardar el refresh token en claro en la BD.
sha256(token), como los tokens de recuperación de 03-03: verificar no exige recuperar. - Consejo: registra
jtiysuben los logs de acceso (nunca el token entero): auditar "qué hizo este token" vale oro en un incidente.
Ejercicios
- El ataque, con tus manos: usando
crear_sesion_MAL, genera el token de("ana.perez", "paciente"), modifícalo para obtener rolmedicoy verifica queleer_sesion_MALlo acepta. Después intenta el mismo ataque contra un token emitido poremitir()y explica exactamente en qué línea deverificar()muere y con qué excepción. - Auditoría de un decode: este código verifica los tokens que MediNube emite para la Farmacia Robles. Encuentra los tres problemas:
jwt.decode(token, publica, algorithms=["EdDSA", "HS256"], options={"verify_exp": False}). - Diseño de rotación: describe (sin código, 5-8 líneas) el procedimiento completo para rotar la clave de firma del portal usando el
kid, de modo que ninguna sesión activa se corte: qué se crea, qué firma, qué solo verifica, y cuándo se elimina la clave vieja.
Soluciones
- Con el token
_MAL, el ataque de la primera sección funciona tal cual. Contraemitir(): al editar el payload, la firma Ed25519 ya no corresponde aheader.payload, yjwt.decode(...)dentro deverificar()lanzajwt.InvalidSignatureError(la primera comprobación criptográfica). Para "re-firmar" necesitaría la clave privada, que vive en el gestor. Si además cambiara elkida uno inventado, moriría antes, en elraisede "kid desconocido". - (a)
algorithms=["EdDSA", "HS256"]: lista no cerrada de verdad — permite la confusión de algoritmos: un atacante fabrica un token HS256 firmado con la clave pública (conocida) como clave HMAC; la lista debe ser["EdDSA"]a secas. (b)verify_exp: False: los tokens nunca caducan; un token filtrado en un log de 2026 sigue abriendo puertas en 2030. (c) Faltanaudienceeissuer: un token emitido para otro servicio (u otro emisor cuya pública conozcamos) se aceptaría aquí. Bonus: sinoptions={"require": [...]}, un token sinexppasaría incluso converify_expactivado. - Se genera el par nuevo en el gestor bajo
kid2027-01→ se añade aCLAVESen todos los verificadores primero (despliegue 1: nadie firma aún con ella, todos podrían verificarla) → se cambiaKID_ACTIVOa la nueva (despliegue 2: los tokens nuevos salen conkid: 2027-01; los viejos siguen verificando con la anterior) → se espera al menosTTL_ACCESS+leeway(más la vida del refresh si también firma algo) → se retira la clave vieja deCLAVESy se destruye en el gestor. Ninguna sesión viva se corta porque durante toda la ventana ambas claves verifican. Es rotación sin parada: la misma coreografía quev1/v2en 06-01.
Conclusión
La deuda más antigua del curso está saldada: el eyJ... de 01-02 era Base64 travestido de token, y ahora el portal emite JWT EdDSA con claims completos, expectativas cerradas en la verificación, access de 15 minutos, refresh rotatorio guardado como hash y kid para rotar la firma sin cortar sesiones. Por el camino has aprendido a desconfiar del token que opina sobre su propia verificación — alg: none y la confusión RS256→HS256 son la misma lección: el verificador manda.
Con esto, MediNube ya no tiene piezas pendientes: todo lo que el curso señaló como "heredado" tiene su versión correcta. La próxima lección es distinta a todas: no aprenderás nada nuevo — vas a auditar. Reuniremos todos los _MAL del curso en una galería de errores organizada por familias, construiremos la checklist del revisor y te enfrentarás a fragmentos de código heredado con varios errores combinados, como en una revisión real. Es hora de entrenar la mirada. Nos vemos en la auditoría.
Curso de Criptografía Aplicada
Módulo 1: Fundamentos de la Criptografía
- Qué es la criptografía y para qué sirve
- Codificación, ofuscación y cifrado
- Aleatoriedad y entropía
- El principio de Kerckhoffs y las reglas de oro
Módulo 2: Criptografía Simétrica
- Cifrado simétrico: AES y ChaCha20
- Modos de operación
- Cifrado autenticado (AEAD)
- Derivación de claves (KDF)
Módulo 3: Hashes, MAC y Contraseñas
- Funciones hash criptográficas
- Autenticación de mensajes con HMAC
- Almacenamiento seguro de contraseñas
Módulo 4: Criptografía Asimétrica
- Fundamentos de clave pública y RSA
- Criptografía de curva elíptica
- Firmas digitales
- Intercambio de claves: Diffie-Hellman
- Cifrado híbrido
Módulo 5: PKI, Certificados y TLS
- Certificados X.509 y autoridades de certificación
- TLS en la práctica
- Gestión del ciclo de vida de certificados
