- Sicurezza
- Backend
JWT: anatomia di un token e gli errori che lo rendono inutile
Base64url non è cifratura e decodificare non è verificare. Struttura di un token, attacchi classici (alg none, confusione RS256/HS256) e una checklist di verifica lato server.
I JSON Web Token sono ovunque: sessioni applicative, API tra microservizi, OAuth 2.0, OpenID Connect. Sono anche, con buona probabilità, la primitiva di sicurezza più fraintesa in circolazione — non perché siano complicati, ma perché il loro aspetto suggerisce garanzie che non offrono. Un JWT sembra opaco, e non lo è. Sembra sicuro di per sé, e non lo è.
Questo articolo parte dalla struttura, arriva agli attacchi classici che sfruttano esattamente quei fraintendimenti, e si chiude con una checklist di verifica lato server.
Le tre parti di un token
Un JWT firmato è una stringa divisa in tre sezioni separate da un punto: header, payload e firma, ciascuna codificata in base64url. Ecco un token reale, firmato con HMAC-SHA256 e il segreto «un-segreto-di-esempio»:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1hcmlvIFJvc3NpIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9
.4C5yh-XyLQxIRkCJeVA4M3N-kPKQ9nIbr2_i5-sJRcYL'header dichiara l'algoritmo di firma e il tipo. Il payload contiene i claim, cioè le affermazioni sul soggetto. La firma lega insieme i primi due segmenti e la chiave.
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{
"sub": "1234567890",
"name": "Mario Rossi",
"iat": 1516239022,
"exp": 1516242622
}La firma si calcola sulla concatenazione dei primi due segmenti già codificati, separati da un punto — non sul JSON originale. È un dettaglio che conta: significa che la verifica non dipende da come il JSON viene riserializzato, e che qualsiasi modifica ai byte trasmessi invalida la firma.
const signingInput = base64url(header) + "." + base64url(payload);
const signature = hmacSha256(signingInput, secret);
const token = signingInput + "." + base64url(signature);Base64url non è cifratura
È il fraintendimento numero uno, e ha conseguenze immediate. Base64url è una codifica, non una cifratura: converte byte in caratteri sicuri per gli URL, e chiunque può invertirla senza alcuna chiave. Il payload di un JWT firmato è leggibile da chiunque intercetti il token, dall'utente stesso, da un log, da un proxy.
echo 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1hcmlvIFJvc3NpIn0' | base64 -d
# {"sub":"1234567890","name":"Mario Rossi"}Decodificare non è verificare
Il secondo fraintendimento è più sottile e più pericoloso. Aprire un JWT e leggerne il contenuto è un'operazione che non richiede nulla: nessuna chiave, nessuna fiducia. Verificarlo significa ricalcolare la firma con la chiave corretta e confrontarla con quella ricevuta. Sono due operazioni completamente diverse, e diverse librerie le espongono con nomi pericolosamente simili.
- decode() — legge i claim senza controllare nulla. Utile per ispezionare un token, per il debug, per leggere l'issuer prima di sapere quale chiave usare. Mai per decidere se un utente è autenticato.
- verify() — ricalcola la firma, controlla scadenza e claim, e fallisce se qualcosa non torna. È l'unica funzione le cui uscite si possono usare per prendere decisioni di autorizzazione.
Gli attacchi classici
L'algoritmo «none»
Lo standard prevede il valore alg: none per i token non firmati, pensato per contesti in cui l'integrità è già garantita da un altro livello. Alcune implementazioni storiche lo accettavano in ingresso: bastava riscrivere l'header, modificare il payload a piacere e lasciare vuoto il terzo segmento.
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1hcmlvIFJvc3NpIn0.
↑
firma assente: il token finisce con un puntoLe librerie mantenute oggi rifiutano questo caso per impostazione predefinita, ma il principio che se ne ricava resta valido e generale: la difesa non è sperare che la libreria si comporti bene, è non lasciare che sia il token a dichiarare come va verificato.
La confusione tra algoritmo simmetrico e asimmetrico
È l'attacco più elegante della categoria. Con RS256 il server firma con una chiave privata e verifica con la corrispondente chiave pubblica, che per definizione è nota a tutti. Con HS256 la stessa chiave firma e verifica.
Se il codice di verifica sceglie l'algoritmo leggendolo dall'header del token, un attaccante può cambiare alg da RS256 a HS256 e firmare un token arbitrario usando come segreto HMAC la chiave pubblica del server. Il server, seguendo l'header, verifica in HMAC con quella stessa chiave pubblica — e la firma torna.
// Vulnerabile: l'algoritmo arriva dal token, cioè dall'attaccante.
jwt.verify(token, key);
// Corretto: l'algoritmo è deciso dal server e non è negoziabile.
jwt.verify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});La regola generale, valida ben oltre i JWT: nessun dato che arriva dall'esterno deve poter scegliere come viene validato.
La firma semplicemente non verificata
Meno spettacolare dei precedenti, e molto più comune nella pratica: un middleware che chiama decode() al posto di verify(), un client che legge i claim per popolare l'interfaccia e un backend che si fida di quel dato, un servizio interno che considera «già verificato» un token passato da un altro servizio. In un'architettura a microservizi quest'ultimo caso è la regola, non l'eccezione: ogni servizio che prende decisioni di autorizzazione deve verificare in proprio.
I claim che vanno controllati
| Claim | Significato | Perché va controllato |
|---|---|---|
| exp | Scadenza | Un token senza scadenza è una password permanente. |
| nbf | Non valido prima di | Evita l'uso anticipato di token pre-emessi. |
| iat | Emesso il | Permette di rifiutare token troppo vecchi anche se non scaduti. |
| iss | Emittente | Impedisce di accettare token emessi da un altro sistema. |
| aud | Destinatario | Impedisce che un token valido per un servizio ne apra un altro. |
| sub | Soggetto | L'identificatore stabile dell'utente. Non usate l'email: cambia. |
| jti | Identificativo del token | Serve per la revoca e per rilevare i replay. |
Il controllo di aud è quello che viene saltato più spesso, ed è quello che separa un'architettura a più servizi da una in cui un token rubato a un servizio marginale apre l'API principale.
Il problema della revoca
Un JWT è autocontenuto: il server verifica la firma e si fida del contenuto, senza consultare un database. È il motivo per cui i JWT scalano bene, ed è anche il loro limite strutturale. Un token emesso resta valido fino alla scadenza, qualunque cosa succeda nel frattempo — logout, cambio password, revoca dei permessi, licenziamento.
- Scadenze brevi per gli access token, nell'ordine dei minuti, con refresh token a vita più lunga e revocabili perché salvati lato server.
- Una denylist dei jti revocati, con voci che scadono da sole insieme al token: torna un po' di stato, ma solo per i casi eccezionali.
- Un timestamp per utente del tipo «token emessi prima di questo istante non valgono più», da azzerare al cambio password: costa una lettura, e risolve il caso più frequente.
Dove salvare il token nel browser
Non esiste una risposta senza compromessi, e diffidate di chi la propone. In localStorage il token è leggibile da qualsiasi JavaScript della pagina: una singola vulnerabilità XSS, anche in una dipendenza di terze parti, lo espone. In un cookie con HttpOnly e Secure il JavaScript non lo vede, ma il browser lo invia automaticamente, il che riapre la superficie CSRF — mitigabile con SameSite e con i token anti-CSRF.
Il compromesso più usato oggi è tenere l'access token in memoria, quindi non persistito da nessuna parte, e il refresh token in un cookie HttpOnly con SameSite. Costa un giro di refresh a ogni ricaricamento di pagina, ed è generalmente il prezzo più basso tra quelli disponibili.
Checklist di verifica
- Verificate sempre la firma sul server, con verify() e mai con decode().
- Fissate voi la lista degli algoritmi ammessi; non leggetela dall'header del token.
- Controllate exp, e controllate anche iss e aud se il token attraversa più di un servizio.
- Tenete le scadenze brevi e prevedete un meccanismo di revoca prima di averne bisogno.
- Non mettete dati riservati nel payload: è leggibile da chiunque.
- Usate una libreria mantenuta. La crittografia scritta in casa fallisce nei modi che non vi aspettate.
- Servite tutto su HTTPS: la firma protegge dall'alterazione, non dall'intercettazione.
L'autore
Francesco Margiotta Casaluci è backend engineer: progetta e sviluppa microservizi in Java e Spring Boot, pipeline dati e piattaforme cloud-native. Scrive di quello che implementa, e implementa gli strumenti gratuiti pubblicati su questo sito.
Leggi il profilo completo