JWT: quando la chiave pubblica diventa il segreto
Un JSON Web Token è tre pezzi separati da punti:
header.payload.signature, ciascuno in base64url. Header e payload
sono JSON leggibili da chiunque — un JWT non è cifrato, è
firmato. Tutta la sicurezza sta in un punto solo: che il server, ricevendo il
token, ricontrolli che la firma sia valida. È qui che le cose vanno storte.
Difetto 1: alg:none
La specifica JWT prevede un algoritmo chiamato none: significa
"token non firmato", pensato per casi in cui l'integrità è garantita altrove.
Nell'header:
{ "alg": "none", "typ": "JWT" }
Un token alg:none ha la terza parte vuota. Il
problema nasce quando la libreria di verifica legge l'algoritmo
dall'header del token stesso e lo asseconda. L'attaccante prende un
token qualsiasi, cambia il payload ("role":"user" →
"role":"admin"), imposta alg:none, cancella la firma:
base64url({"alg":"none","typ":"JWT"}) + "." +
base64url({"sub":"1","role":"admin"}) + "."
Se il server verifica con qualcosa come
jwt.verify(token) senza vincolare l'algoritmo, accetta il token come
"correttamente non firmato". Nessun segreto, controllo di ruolo saltato.
Difetto 2: confusione RS256 → HS256
Questo è più sottile ed è quello che sorprende anche sviluppatori attenti. Servono due nozioni:
- RS256 è asimmetrico: il server firma con la chiave privata e verifica con la chiave pubblica. La chiave pubblica è, per definizione, pubblica.
- HS256 è simmetrico: la stessa chiave segreta firma e verifica. È un HMAC.
Immagina un server che si aspetta RS256 ma usa una funzione di verifica generica che sceglie l'algoritmo dall'header e prende come "chiave" lo stesso materiale in entrambi i casi. L'attaccante:
- procura la chiave pubblica RS256 (spesso esposta su un endpoint JWKS, in un certificato TLS, o su GitHub);
- forgia un token con header
alg:HS256e il payload che vuole; - calcola l'HMAC-SHA256 del token usando la chiave pubblica come segreto HMAC.
import jwt # PyJWT
public_pem = open('public.pem').read()
forged = jwt.encode(
{"sub": "1", "role": "admin"},
key=public_pem, # la chiave PUBBLICA usata come segreto HMAC
algorithm="HS256",
)
Il server riceve il token, legge alg:HS256 dall'header, prende la
chiave pubblica (che ha già, per verificare RS256) e la usa come segreto HMAC per
ricalcolare la firma. Combacia. Il token è accettato. L'attaccante ha firmato un
token valido conoscendo solo materiale pubblico.
La radice comune
Entrambi i difetti hanno la stessa causa: lasciare che sia il token a
dichiarare come va verificato. Il campo alg nell'header è
input dell'attaccante. Fidarsene per scegliere l'algoritmo di verifica è come
chiedere al lucchetto quale chiave usare.
La regola che chiude tutto
Vincola sempre l'algoritmo dal lato server, con una lista esplicita, e ignora ciò che dice l'header:
# giusto: il server decide, il token non ha voce in capitolo
jwt.decode(token, key=public_pem, algorithms=["RS256"])
# sbagliato: qualsiasi cosa che non fissi algorithms, o che accetti
# la lista dall'header, o che passi 'verify=False'
Corollari che vale la pena scrivere in ogni checklist:
- Non usare mai una funzione "verifica e basta" che accetta ogni algoritmo. Molte librerie storiche lo facevano per default; le versioni moderne obbligano a passare la lista, proprio per questo.
- Tieni separato il tipo di chiave dal tipo di algoritmo: una chiave RSA non deve poter finire in una funzione HMAC. Alcune librerie ora rifiutano esplicitamente questa combinazione.
- Verifica anche
exp,audeiss: una firma valida su un token scaduto o destinato a un altro servizio non basta. - Se puoi, preferisci token opachi lato server (session id + store) quando non ti serve davvero la verifica stateless. Un JWT è una scelta, non un default.