A 01-02 vas aprendre a reconèixer el prefix eyJ com l'empremta d'un JSON en Base64, i vam descobrir que el token de sessió del portal de MediNube era exactament això: un JSON codificat, sense signatura, que qualsevol podia editar. Vam prometre tornar amb les eines necessàries per arreglar-ho, i aquest dia és avui: tenim HMAC (03-02), signatures asimètriques (04-03) i un gestor de claus (06-01). En aquesta lliçó MediNube salda el seu deute més antic: demostrem l'atac contra el token heretat, entenem què és un JWT de veritat, aprenem a fer-lo servir amb PyJWT esquivant els seus paranys clàssics (alg: none, confusió d'algorismes, claims sense validar) i construïm medinube/sessions.py, el mòdul de sessions definitiu del portal.

Contingut

  1. El deute de 01-02: el token que qualsevol podia editar
  2. Sessions amb estat vs tokens autocontinguts
  3. Anatomia d'un JWT: header, payload, signature
  4. Signar i verificar amb PyJWT: HS256 i EdDSA
  5. Els atacs clàssics contra JWT i la seva defensa
  6. Expiració curta i refresh tokens
  7. Revocació: el preu de l'autocontingut
  8. On desar el token al navegador
  9. medinube/sessions.py: la implementació final

El deute de 01-02: el token que qualsevol podia editar

Aquest és el codi heretat que el portal de MediNube ha estat fent servir per a les sessions — el nostre _MALAMENT més antic:

# medinube/sessions_legacy.py — NO FER SERVIR
import base64, json

def crear_sessio_MALAMENT(usuari: str, rol: str) -> str:
    dades = {"usuari": usuari, "rol": rol}
    return base64.urlsafe_b64encode(json.dumps(dades).encode()).decode()

def llegir_sessio_MALAMENT(token: str) -> dict:
    return json.loads(base64.urlsafe_b64decode(token))

L'atac no requereix ni criptoanàlisi ni eines: Ana Pérez (o MalloryClinic amb el compte d'Ana) obre la consola del navegador i fa això:

>>> token = crear_sessio_MALAMENT("ana.perez", "pacient")
>>> token
'eyJ1c3VhcmkiOiAiYW5hLnBlcmV6IiwgInJvbCI6ICJwYWNpZW50In0='

# L'"atacant" descodifica, edita i recodifica:
>>> dades = json.loads(base64.urlsafe_b64decode(token))
>>> dades["rol"] = "metge"                        # ← ascens instantani
>>> fals = base64.urlsafe_b64encode(json.dumps(dades).encode()).decode()
>>> llegir_sessio_MALAMENT(fals)
{'usuari': 'ana.perez', 'rol': 'metge'}        # el servidor s'ho creu

Regla d'or 4 en la seva versió més cara: codificar no és xifrar... i tampoc és autenticar. Base64 és un canvi d'alfabet, no una protecció. El servidor confia en una dada que ha fabricat el client. La solució no és amagar el contingut (l'usuari pot saber el seu propi rol), és que el servidor pugui verificar que ell mateix ho ha emès i ningú ho ha tocat: una signatura. Això és, exactament, un JWT.

Sessions amb estat vs tokens autocontinguts

Abans de córrer cap a JWT, honestedat d'enginyer: l'alternativa clàssica continua sent excel·lent. Una sessió amb estat desa les dades al servidor i dona al navegador només un identificador aleatori opac (cookie amb 128+ bits del CSPRNG, regla 3 i 5). Un token autocontingut (JWT) porta les dades amb ell, signades.

Aspecte Sessió amb estat (cookie + taula) Token autocontingut (JWT)
El servidor desa Una fila per sessió Res (la clau de signatura)
Revocació Trivial: DELETE FROM sessions Difícil (ho veurem: jti + llista)
Verificació Consulta a BD/Redis per petició Només criptografia, sense I/O
Escenari ideal Aplicació monolítica amb la seva BD al costat Microserveis, APIs, tercers que verifiquen
Contingut visible No (l'id és opac) Sí: el payload és llegible per disseny
Caducitat La decideix el servidor en viu Gravada al token en emetre'l

La taula diu una cosa incòmoda per a l'hype: si la teva aplicació és un monòlit amb una BD a un mil·lisegon, la cookie de sessió clàssica és més simple i més segura (revocació instantània). JWT guanya quan la verificació ha d'ocórrer lluny de l'emissor: entre els microserveis de MediNube sense tocar la BD a cada salt, o quan la Farmàcia Robles ha de verificar un token emès per MediNube sense trucar-nos. Aquest és el nostre cas d'ús real, així que endavant.

Anatomia d'un JWT: header, payload, signature

Un JWT (JSON Web Token, RFC 7519) són tres blocs Base64URL separats per punts: header.payload.signature. Com que header i payload són JSON, un JWT autèntic ensenya... dos o tres eyJ! Descodifiquem-ne un a mà per perdre-li tot el misteri:

import base64, json

token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
         "eyJzdWIiOiJhbmEucGVyZXoiLCJyb2wiOiJwYWNpZW50ZSIsImlzcyI6Imh0dHBzOi8v"
         "YXBpLm1lZGludWJlLmV4YW1wbGUiLCJleHAiOjE3ODM1MDQwMDB9."
         "4v0X9K1v2xLQnO7Yd3rZ8mPqW5tJc6hB0aFgN_sUwEo")

def b64url_decode(part: str) -> bytes:
    # Base64URL omet el padding '='; cal reposar-lo per descodificar
    return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))

header, payload, signatura = token.split(".")
print(json.loads(b64url_decode(header)))   # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload)))  # {'sub': 'ana.perez', 'rol': 'paciente', ...}
# signatura: bytes crus, NO es JSON — es HMAC-SHA256(clau, header + "." + payload)

Observa: qualsevol pot llegir el header i el payload (només és Base64URL). La signatura no amaga res, garanteix integritat i origen — com el tag de GCM a 02-03 o la signatura de les receptes a 04-03. Mai posis secrets en un payload JWT.

Els claims estàndard que farem servir sempre:

Claim Significat Per què importa
sub Subject: de qui parla el token (ana.perez) La identitat
exp Expiration: caducitat (timestamp Unix) Sense ell, un token robat val per sempre
iat Issued at: moment d'emissió Auditoria; permet invalidar "tot l'anterior a"
iss Issuer: qui l'ha emès (https://api.medinube.example) Evita acceptar tokens d'un altre emissor
aud Audience: per a qui és (farmacia-robles) Evita reutilitzar un token d'un servei en un altre
jti JWT id: identificador únic del token La peça que farà possible la revocació

Signar i verificar amb PyJWT: HS256 i EdDSA

PyJWT (pip install pyjwt[crypto]) fa la feina. La primera decisió és l'algorisme, i ja coneixes els dos candidats:

  • HS256 = HMAC-SHA256 (03-02): simètric. Qui pot verificar també pot emetre. Perfecte entre serveis interns de MediNube que comparteixen la clau (del gestor de 06-01).
  • RS256 (RSA-PSS no, compte: RS256 és PKCS#1 v1.5; PS256 és PSS) i EdDSA (Ed25519, 04-03): asimètrics. Es signa amb la privada i es verifica amb la pública. Imprescindible quan verifiquen tercers: la Farmàcia Robles verifica amb la nostra clau pública sense poder emetre tokens — amb HS256 hauríem de donar-li la clau i podria fabricar tokens de MediNube.
import jwt, time

# --- Intern (HS256): entre serveis de MediNube ---
CLAU_HS = obtenir_del_gestor("medinube/produccio/jwt-intern")  # 32 bytes CSPRNG

token = jwt.encode(
    {"sub": "servei-receptes", "iss": "https://api.medinube.example",
     "aud": "servei-historials", "iat": int(time.time()),
     "exp": int(time.time()) + 300},          # 5 minuts: intern = curt
    CLAU_HS, algorithm="HS256",
)

# --- Extern (EdDSA): la Farmacia Robles verifica sense poder emetre ---
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

privada = carregar_privada_del_gestor("medinube/produccio/jwt-firma-ed25519")
token_farmacia = jwt.encode(
    {"sub": "recepta-8842", "iss": "https://api.medinube.example",
     "aud": "farmacia-robles", "exp": int(time.time()) + 900},
    privada, algorithm="EdDSA",
)
# La Farmacia Robles nomes necessita la clau PUBLICA per verificar:
dades = jwt.decode(token_farmacia, publica_medinube, algorithms=["EdDSA"],
                   audience="farmacia-robles", issuer="https://api.medinube.example")

Explicació de les decisions: les claus surten del gestor (06-01), mai del codi; l'exp intern és de minuts (els tokens interns es demanen i es llencen); i triem EdDSA sobre RS256 per les mateixes raons que a 04-03 — claus i signatures petites, sense decisions de padding que espatllar, rendiment excel·lent.

Els atacs clàssics contra JWT i la seva defensa

La història de JWT és plena d'implementacions trencades. Els tres atacs que has de conèixer ataquen un mateix punt feble: deixar que el token decideixi com es verifica ell mateix.

  1. alg: none: l'especificació original permetia {"alg": "none"} = "sense signatura". Les llibreries antigues acceptaven aquest header... escrit per l'atacant: fabricaves un token amb el payload que volguessis, alg: none, signatura buida, i endins. És crear_sessio_MALAMENT disfressat d'estàndard.
  2. Confusió RS256 → HS256: l'atacant agafa un token RS256 legítim, canvia el header a HS256 i signa amb HMAC fent servir... la clau pública (que és pública) com a clau HMAC. Una llibreria ingènua que rebi "la clau" com un blob i obeeixi l'alg del token verificarà l'HMAC amb la pública i passarà! La pública era per verificar RSA, no per ser clau simètrica secreta.
  3. Claims sense validar: signar bé i després no comprovar exp (tokens eterns), aud (un token emès per a la Farmàcia Robles reutilitzat contra l'API d'historials) o iss (acceptar tokens d'un altre emissor amb clau coneguda).

La defensa amb PyJWT modern és una sola disciplina: el verificador fixa les seves expectatives; el token no opina.

def verificar_MALAMENT(token):
    # MAI: sense algorithms= (PyJWT modern ho rebutja, versions velles no),
    # sense audience, sense issuer... el token mana.
    return jwt.decode(token, CLAU, options={"verify_signature": False})  # ← horror

def verificar_be(token: str, publica) -> dict:
    return jwt.decode(
        token,
        publica,
        algorithms=["EdDSA"],                    # llista TANCADA: mata 'none' i la confusio
        audience="farmacia-robles",              # aquest token es PER a mi
        issuer="https://api.medinube.example",   # i l'ha emes qui espero
        options={"require": ["exp", "iss", "aud", "sub"]},  # claims obligatoris
        leeway=30,                               # 30 s de tolerancia de rellotge
    )

Punt per punt: algorithms=["EdDSA"] és obligatori sempre — en ser una llista tancada triada per tu, alg: none i el canvi a HS256 moren aquí (PyJWT a més es nega a fer servir una clau pública asimètrica amb algorismes HMAC, defensa en profunditat); audience i issuer tanquen la reutilització entre serveis; require converteix l'absència d'un claim en error en lloc de "no es comprova"; leeway evita falsos rebutjos per rellotges lleugerament desincronitzats. Qualsevol fallada llança una excepció (jwt.InvalidSignatureError, jwt.ExpiredSignatureError...): captura-les i retorna 401, sense filtrar detalls al client.

Expiració curta i refresh tokens

Si un JWT robat és vàlid fins al seu exp, la primera mitigació és òbvia: exp curt (minuts). Però ningú vol tornar-se a autenticar cada 15 minuts. La solució estàndard és la parella access + refresh:

sequenceDiagram
    participant N as Navegador (Ana)
    participant A as api.medinube.example
    participant BD as BD (taula refresh)
    N->>A: login (usuari + contrasenya → Argon2id, 03-03)
    A->>BD: desa sha256(refresh)
    A-->>N: access JWT (15 min) + refresh token (7 dies)
    Note over N,A: ...passen 15 minuts, l'access caduca...
    N->>A: POST /token/refresh (refresh token)
    A->>BD: existeix sha256(refresh) i no esta usat/revocat?
    A->>BD: marca el vell com usat, desa sha256(nou)
    A-->>N: access nou + refresh NOU (rotacio)

Les peces del disseny, totes conegudes:

  • L'access token és el JWT: autocontingut, verificable sense BD, vida de 15 minuts.
  • El refresh token no necessita ser un JWT: és un secret opac del CSPRNG (secrets.token_urlsafe(32)) que que viu a la BD... desat com a sha256(token), exactament el patró dels tokens de recuperació de 03-03: un bolcat de la BD no regala sessions.
  • Rotació amb detecció de robatori: cada ús del refresh n'emet un de nou i invalida l'anterior. Si algú presenta un refresh ja usat, hi ha dos posseïdors → es revoca la família sencera i Ana ha de tornar a fer login.

Revocació: el preu de l'autocontingut

"Tancar sessió a tots els dispositius", "aquest empleat ja no treballa aquí", "s'ha filtrat un token": amb sessions de BD és un DELETE; amb JWT, el token signat continua sent criptogràficament vàlid fins al seu exp. No hi ha signatura que "des-signar". Opcions, de menys a més estat:

  1. TTL curts com a mitigació principal: amb access de 15 minuts, la finestra d'un token robat és de 15 minuts, i la revocació real ocorre al refresh (que sí que és a la BD). És la moralitat de les vides curtes dels certificats de 05-03, calcada: quan revocar és difícil, caduca ràpid.
  2. Llista de revocats per jti: una taula/Redis amb els jti revocats fins al seu exp. El verificador consulta la llista... i acabes de reintroduir una consulta per petició — l'híbrid honest: només la consulten els endpoints sensibles.
  3. Invalidació per usuari: desar per usuari un no_valid_abans_de; tot token amb iat anterior mor. Barat (un valor per usuari, cachejable) i cobreix "tanca totes les meves sessions".

MediNube fa servir 1 + 3: access de 15 minuts i marca per usuari per al botó del pànic.

Menció ràpida per completar el mapa: a més de JWS (signat, el que hem fet), existeix JWE (JSON Web Encryption): el token va xifrat, no només signat, per a payloads que el client no ha de llegir. Cinc parts en lloc de tres, mateixa família d'estàndards (JOSE). MediNube no ho necessita — no posem res secret al payload — però l'has de reconèixer quan el vegis.

On desar el token al navegador

Breu i pràctic, perquè aquí es perden moltes batalles guanyades per la criptografia:

  • localStorage: accessible des de JavaScript → qualsevol XSS exfiltra el token. Evita-ho per a tokens de sessió.
  • Cookie amb HttpOnly; Secure; SameSite=Lax (o Strict): JavaScript no la pot llegir (HttpOnly neutralitza el robatori per XSS), només viatja per HTTPS (Secure, amb l'HSTS de 05-02 al darrere) i SameSite talla la major part del CSRF. És l'opció per defecte correcta per al portal de MediNube.

El JWT dins d'una cookie HttpOnly combina el millor dels dos mons: verificació sense estat al servidor, robatori per XSS bloquejat al client. Regla d'or 9: la criptografia no substitueix la resta de la seguretat — un token perfectament signat a localStorage continua sent un token robat amb un XSS.

medinube/sessions.py: la implementació final

Tot junt: EdDSA, claims complets, expectatives tancades i kid (key id) al header per poder rotar la clau de signatura sense invalidar les sessions vives — la mateixa jugada que el byte 0x01/0x02 dels historials, ara en JWT:

# medinube/sessions.py — sessions del portal, versio definitiva
import secrets, time, jwt

ISS = "https://portal.medinube.example"
AUD = "portal"
TTL_ACCES = 15 * 60

# Claus de signatura versionades per kid, servides pel gestor (06-01).
# "2026-07" signa; "2026-01" nomes verifica fins que mori el seu ultim token.
CLAUS = {
    "2026-07": carregar_parell_ed25519("medinube/produccio/jwt-portal/2026-07"),
    "2026-01": carregar_parell_ed25519("medinube/produccio/jwt-portal/2026-01"),
}
KID_ACTIU = "2026-07"

def emetre(usuari: str, rol: str) -> str:
    ara = int(time.time())
    return jwt.encode(
        {"sub": usuari, "rol": rol, "iss": ISS, "aud": AUD,
         "iat": ara, "exp": ara + TTL_ACCES, "jti": secrets.token_hex(16)},
        CLAUS[KID_ACTIU].privada,
        algorithm="EdDSA",
        headers={"kid": KID_ACTIU},          # ← quina clau verifica aquest token
    )

def verificar(token: str) -> dict:
    kid = jwt.get_unverified_header(token).get("kid")
    if kid not in CLAUS:                      # kid desconegut = token rebutjat
        raise jwt.InvalidTokenError("kid desconegut")
    dades = jwt.decode(
        token, CLAUS[kid].publica,
        algorithms=["EdDSA"], audience=AUD, issuer=ISS,
        options={"require": ["exp", "iat", "sub", "jti"]}, leeway=30,
    )
    if dades["iat"] < no_valid_abans_de(dades["sub"]):   # boto del panic
        raise jwt.InvalidTokenError("sessions de l'usuari invalidades")
    return dades

L'única dada que es llegeix del token abans de verificar és el kid, i només per triar entre claus en què nosaltres ja confiem (si no és a CLAUS, fora): el token mai no aporta material de verificació, només assenyala quina de les nostres fer servir. Compara aquest mòdul amb crear_sessio_MALAMENT: mateix problema, dos mòduls de curs de distància.

Errors Comuns i Consells

  • Error: creure que el JWT està xifrat perquè "no es llegeix". És Base64URL: es llegeix amb dues línies de Python. Res de secret al payload; per a això existeix JWE.
  • Error: jwt.decode(token, clau) sense algorithms=[...]. A PyJWT modern falla, però en llibreries/versions antigues és la porta d'alg: none i de la confusió RS256→HS256. Escriu la llista tancada sempre, en tots els llenguatges.
  • Error: fer servir HS256 amb tercers. Donar la clau de verificació a la Farmàcia Robles és donar-li la d'emissió. Tercers = asimètric (EdDSA/RS256), pública per a ells, privada al gestor.
  • Error: access tokens d'hores o dies "per no molestar l'usuari". Per a això hi ha el refresh token; l'access llarg converteix cada robatori en una sessió persistent sense revocació.
  • Error: desar el refresh token en clar a la BD. sha256(token), com els tokens de recuperació de 03-03: verificar no exigeix recuperar.
  • Consell: registra jti i sub als logs d'accés (mai el token sencer): auditar "què ha fet aquest token" val or en un incident.

Exercicis

  1. L'atac, amb les teves mans: fent servir crear_sessio_MALAMENT, genera el token de ("ana.perez", "pacient"), modifica'l per obtenir el rol metge i verifica que llegir_sessio_MALAMENT l'accepta. Després intenta el mateix atac contra un token emès per emetre() i explica exactament en quina línia de verificar() mor i amb quina excepció.
  2. Auditoria d'un decode: aquest codi verifica els tokens que MediNube emet per a la Farmàcia Robles. Troba els tres problemes: jwt.decode(token, publica, algorithms=["EdDSA", "HS256"], options={"verify_exp": False}).
  3. Disseny de rotació: descriu (sense codi, 5-8 línies) el procediment complet per rotar la clau de signatura del portal fent servir el kid, de manera que cap sessió activa no es talli: què es crea, què signa, què només verifica, i quan s'elimina la clau vella.

Solucions

  1. Amb el token _MALAMENT, l'atac de la primera secció funciona tal qual. Contra emetre(): en editar el payload, la signatura Ed25519 ja no correspon a header.payload, i jwt.decode(...) dins de verificar() llança jwt.InvalidSignatureError (la primera comprovació criptogràfica). Per "tornar a signar" necessitaria la clau privada, que viu al gestor. Si a més canviés el kid per un d'inventat, moriria abans, al raise de "kid desconegut".
  2. (a) algorithms=["EdDSA", "HS256"]: llista no tancada de veritat — permet la confusió d'algorismes: un atacant fabrica un token HS256 signat amb la clau pública (coneguda) com a clau HMAC; la llista ha de ser ["EdDSA"] a soles. (b) verify_exp: False: els tokens mai no caduquen; un token filtrat en un log de 2026 continua obrint portes el 2030. (c) Falten audience i issuer: un token emès per a un altre servei (o un altre emissor la pública del qual coneguem) s'acceptaria aquí. Bonus: sense options={"require": [...]}, un token sense exp passaria fins i tot amb verify_exp activat.
  3. Es genera el parell nou al gestor sota kid 2027-01 → s'afegeix a CLAUS a tots els verificadors primer (desplegament 1: ningú signa encara amb ella, tots podrien verificar-la) → es canvia KID_ACTIU a la nova (desplegament 2: els tokens nous surten amb kid: 2027-01; els vells continuen verificant amb l'anterior) → s'espera almenys TTL_ACCES + leeway (més la vida del refresh si també signa alguna cosa) → es retira la clau vella de CLAUS i es destrueix al gestor. Cap sessió viva es talla perquè durant tota la finestra les dues claus verifiquen. És rotació sense parada: la mateixa coreografia que v1/v2 a 06-01.

Conclusió

El deute més antic del curs està saldat: l'eyJ... de 01-02 era Base64 disfressat de token, i ara el portal emet JWT EdDSA amb claims complets, expectatives tancades a la verificació, access de 15 minuts, refresh rotatori desat com a hash i kid per rotar la signatura sense tallar sessions. Pel camí has après a desconfiar del token que opina sobre la seva pròpia verificació — alg: none i la confusió RS256→HS256 són la mateixa lliçó: el verificador mana.

Amb això, MediNube ja no té peces pendents: tot el que el curs assenyalava com a "heretat" té la seva versió correcta. La propera lliçó és diferent de totes: no aprendràs res de nou — auditaràs. Reunirem tots els _MALAMENT del curs en una galeria d'errors organitzada per famílies, construirem la checklist del revisor i t'enfrontaràs a fragments de codi heretat amb diversos errors combinats, com en una revisió real. És hora d'entrenar la mirada. Ens veiem a l'auditoria.

© Copyright 2026. Tots els drets reservats