francescomargiotta.com
  • 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.

di Francesco Margiotta Casaluci7 min di lettura

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-sJRcY
Gli a capo sono aggiunti per leggibilità: nel token reale i tre segmenti sono su un'unica riga.

L'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"}
Nessuna chiave, nessun segreto: una sola riga di shell.

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 punto
Header con alg: none, payload arbitrario, terzo segmento vuoto.

Le 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

ClaimSignificatoPerché va controllato
expScadenzaUn token senza scadenza è una password permanente.
nbfNon valido prima diEvita l'uso anticipato di token pre-emessi.
iatEmesso ilPermette di rifiutare token troppo vecchi anche se non scaduti.
issEmittenteImpedisce di accettare token emessi da un altro sistema.
audDestinatarioImpedisce che un token valido per un servizio ne apra un altro.
subSoggettoL'identificatore stabile dell'utente. Non usate l'email: cambia.
jtiIdentificativo del tokenServe 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

  1. Verificate sempre la firma sul server, con verify() e mai con decode().
  2. Fissate voi la lista degli algoritmi ammessi; non leggetela dall'header del token.
  3. Controllate exp, e controllate anche iss e aud se il token attraversa più di un servizio.
  4. Tenete le scadenze brevi e prevedete un meccanismo di revoca prima di averne bisogno.
  5. Non mettete dati riservati nel payload: è leggibile da chiunque.
  6. Usate una libreria mantenuta. La crittografia scritta in casa fallisce nei modi che non vi aspettate.
  7. 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

Articoli correlati