Un token JWT rubato non scade da solo e una firma verificata con l’algoritmo sbagliato apre la porta a chiunque sappia leggere la documentazione pubblica della tua API. Sono due dei bug più comuni che troviamo ancora oggi in produzione, anche su stack Node.js aggiornati. Questa guida mostra come costruire, firmare, verificare e revocare i JSON Web Token in modo corretto, con codice testato su Node.js 24.21.0 LTS “Krypton” (rilasciato l’8 settembre 2026), il pacchetto jsonwebtoken 9.0.3 e la libreria jose 6.2.12. Al termine avrai un piccolo servizio di autenticazione completo, con rotazione dei refresh token e revoca via denylist, pronto da adattare al tuo progetto. La guida è pensata per sviluppatori backend che già conoscono le basi di Express o Fastify e vogliono sostituire un’autenticazione fatta in fretta con un’implementazione che regga un audit di sicurezza, non per chi cerca una prima introduzione assoluta a Node.js.

Cos’è un JWT e perché la sicurezza dell’implementazione conta più dello standard

Un JSON Web Token, definito nella RFC 7519, è una stringa compatta divisa in tre parti separate da un punto: header, payload e firma, ciascuna codificata in Base64URL. Lo standard di per sé è solido. Il problema arriva quasi sempre da come i team lo implementano: quale algoritmo firma il token, chi decide quell’algoritmo al momento della verifica, dove viene salvato il token lato client e cosa succede quando un utente cambia password o un dipendente lascia l’azienda ma il suo token è ancora valido per altri 20 minuti.

L’header dichiara l’algoritmo di firma (per esempio HS256 o RS256), il payload contiene i claim (dati sull’utente e sulla sessione) e la firma garantisce che nessuno abbia modificato le prime due parti dopo l’emissione. Il punto debole classico riguarda proprio l’header: se il server si fida ciecamente del campo alg dichiarato dal client, un attaccante può cambiarlo e forzare il server a verificare con una chiave diversa da quella prevista. Le sezioni successive mostrano come chiudere questa porta passo dopo passo.

Prerequisiti: versioni software verificate a settembre 2026

Prima di iniziare, verifica di avere queste versioni installate o aggiorna con npm view <pacchetto> version, dato che i numeri cambiano nel tempo e l’articolo riflette lo stato dei registri npm e del sito Node.js al 17 settembre 2026.

ComponenteVersione verificataNote
Node.js24.21.0 LTS “Krypton”Riga LTS raccomandata, rilasciata l’8 settembre 2026
jsonwebtoken9.0.3Nessuna vulnerabilità diretta nota nella versione attuale secondo l’advisory Snyk
jose6.2.12Libreria alternativa basata su Web Crypto API, supporta ESM nativo
express5.2.1Ramo Express 5, gestione async degli errori integrata
express-jwt8.5.1Middleware di verifica per Express
jwks-rsa4.1.0Client per recuperare chiavi pubbliche da un endpoint JWKS
fastify5.12.5Alternativa a Express usata negli esempi di middleware

Ti servono inoltre una istanza Redis locale (o un container Docker con l’immagine ufficiale redis:7) per la denylist dei token revocati, e OpenSSL per generare le coppie di chiavi RSA usate negli esempi con l’algoritmo RS256. Il tempo stimato per completare tutti i 12 passi, incluso il test finale, è di circa 45 minuti su una macchina con Node.js già installato. Trovi il changelog completo della release LTS nel post ufficiale di Node.js, utile se vuoi verificare se sono usciti aggiornamenti di sicurezza dopo la pubblicazione di questa guida, mentre lo stato delle vulnerabilità note per jsonwebtoken è consultabile in tempo reale sulla pagina advisory di Snyk.

JWT o sessioni server-side: quando conviene l’uno e quando l’altro

Prima di scrivere codice vale la pena chiedersi se un JWT sia davvero la scelta giusta per il tuo progetto, perché non lo è sempre. Le sessioni server-side classiche, con un identificativo opaco salvato in un cookie e lo stato mantenuto in un database o in Redis, restano più semplici da revocare all’istante: basta cancellare la riga corrispondente. Il vantaggio dei JWT emerge quando hai più servizi indipendenti che devono verificare l’identità dell’utente senza fare una chiamata di rete al servizio di autenticazione per ogni singola richiesta, per esempio in un’architettura a microservizi o quando un mobile client comunica con più API gestite da team diversi.

Per un’applicazione monolitica con un solo backend, una sessione tradizionale è spesso la scelta più sobria e con meno insidie da gestire. Per un’architettura distribuita, i JWT tolgono un collo di bottiglia reale, a patto di accettare la complessità aggiuntiva descritta in questa guida: rotazione dei refresh token, denylist per la revoca anticipata e gestione delle chiavi. Non esiste una risposta universalmente corretta, esiste una risposta corretta per l’architettura che hai davanti. Alcuni team adottano uno schema ibrido: JWT a vita brevissima per l’accesso alle API interne, sessione server-side tradizionale per il pannello di amministrazione dove la revoca immediata conta più della scalabilità. Non è una scorciatoia, è un modo pragmatico di applicare lo strumento giusto a ogni superficie della stessa applicazione.

Passo 1: Configurare il progetto Node.js

Crea una nuova cartella e inizializza il progetto con i pacchetti che useremo lungo tutta la guida. Usiamo jsonwebtoken per la firma e la verifica di base, jose per gli esempi con JWKS, express per le rotte HTTP e ioredis per la denylist dei token.

mkdir jwt-sicuro-nodejs && cd jwt-sicuro-nodejs
npm init -y
npm install [email protected] [email protected] [email protected] [email protected] [email protected] ioredis@5 dotenv@16 express-rate-limit@7
npm install -D nodemon@3

Aggiungi "type": "module" al package.json se preferisci lavorare con import ESM (necessario per sfruttare appieno jose), oppure resta su CommonJS se il resto del progetto lo richiede: gli esempi di questa guida funzionano con entrambi gli stili, con piccoli adattamenti alla sintassi di import.

Passo 2: Generare una coppia di chiavi RS256 invece di una chiave condivisa

La scelta tra HS256 (chiave simmetrica condivisa) e RS256 (coppia di chiavi asimmetriche) non è solo una preferenza stilistica. Con HS256 chiunque possa verificare un token conosce anche la chiave che serve per firmarne di nuovi, quindi ogni microservizio che valida i token diventa un potenziale punto da cui firmare token falsi se compromesso. Con RS256 solo il server di autenticazione detiene la chiave privata, mentre tutti gli altri servizi usano la chiave pubblica solo per verificare. Genera la coppia con OpenSSL:

openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in private.pem -out public.pem

Salva private.pem fuori dal repository, per esempio in un secret manager o in una variabile d’ambiente iniettata a runtime. Distribuisci public.pem ai servizi che devono solo verificare i token, oppure esponila tramite un endpoint JWKS se hai più servizi da aggiornare senza ridistribuire il file manualmente.

Passo 3: Firmare un access token in modo corretto

Il punto critico qui è dichiarare esplicitamente l’algoritmo e i claim temporali al momento della firma, senza lasciare che siano impliciti o opzionali. Un access token con vita lunga è comodo in fase di sviluppo e pericoloso in produzione, perché ogni minuto di validità in più è un minuto in cui un token rubato resta utilizzabile.

import fs from 'node:fs';
import jwt from 'jsonwebtoken';

const privateKey = fs.readFileSync('./private.pem');

function firmaAccessToken(utente) {
  return jwt.sign(
    {
      sub: utente.id,
      email: utente.email,
      ruolo: utente.ruolo,
    },
    privateKey,
    {
      algorithm: 'RS256',
      expiresIn: '10m',
      issuer: 'https://api.tuosito.it',
      audience: 'https://tuosito.it',
      jwtid: crypto.randomUUID(),
    }
  );
}

Nota due dettagli spesso trascurati: issuer e audience non sono decorazioni, servono a impedire che un token emesso per un’app venga riutilizzato su un’altra che condivide lo stesso server di autenticazione. Il campo jwtid genera un identificativo univoco (jti) che useremo più avanti per la revoca puntuale del singolo token.

Passo 4: Verificare il token e bloccare l’attacco di alg confusion

Qui si gioca la parte più delicata della guida. L’attacco di “algorithm confusion” sfrutta un errore di implementazione lato verifica: se il codice che controlla il token legge l’algoritmo dall’header del JWT invece di imporlo esplicitamente, un attaccante può costruire un token con alg: HS256 e firmarlo usando la chiave pubblica RSA come se fosse una chiave simmetrica (la chiave pubblica è, per definizione, pubblica). Se il verificatore accetta quell’algoritmo, il token passa come valido. Una variante ancora più diretta è alg: none, che dichiara un token non firmato: alcune librerie datate lo accettavano senza controllare nulla.

Per capire il problema in concreto, ecco cosa contiene l’header di un token manipolato con questo tipo di attacco, decodificato da Base64URL:

{
  "alg": "none",
  "typ": "JWT"
}
.
{
  "sub": "1",
  "ruolo": "amministratore",
  "exp": 9999999999
}
.

Nota la firma vuota dopo l’ultimo punto e il ruolo impostato ad “amministratore” senza che nessuno l’abbia mai firmato. Un verificatore che non impone esplicitamente l’algoritmo atteso può accettare questo token come valido, perché formalmente rispetta la struttura di un JWT. È esattamente lo scenario che l’opzione algorithms mostrata di seguito impedisce.

import fs from 'node:fs';
import jwt from 'jsonwebtoken';

const publicKey = fs.readFileSync('./public.pem');

function verificaAccessToken(token) {
  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'], // pin esplicito, mai lasciare vuoto o dedotto dall'header
    issuer: 'https://api.tuosito.it',
    audience: 'https://tuosito.it',
    clockTolerance: 30, // secondi di tolleranza per lo sfasamento orario
  });
}

L’opzione algorithms: ['RS256'] è la difesa vera e propria: jsonwebtoken rifiuta qualsiasi token il cui header dichiari un algoritmo diverso, incluso none, prima ancora di controllare la firma. Senza questa opzione esplicita, per compatibilità storica alcune versioni delle librerie accettano l’algoritmo indicato nel token stesso, il che vanifica lo scopo della verifica. La cheat sheet OWASP dedicata alla sicurezza REST ribadisce questo punto come prima regola per chi implementa la verifica dei JWT lato server, e la categoria A07:2025 Authentication Failures dell’OWASP Top 10:2025 elenca la gestione debole dei token tra le cause ricorrenti di questa classe di vulnerabilità.

Passo 5: Validare issuer, audience, subject e claim temporali

Un token con firma valida non è automaticamente un token da fidarsi. Dopo la verifica crittografica, il codice deve controllare che i claim abbiano senso nel contesto della richiesta. In pratica: iss deve corrispondere al tuo server di autenticazione, aud al servizio che sta ricevendo la richiesta, sub a un utente che esiste ancora nel tuo database (non basta che il token sia firmato, l’utente potrebbe essere stato cancellato dopo l’emissione) ed exp deve essere nel futuro rispetto all’orologio del server.

Il claim nbf (not before) è meno usato ma utile per token emessi in anticipo, per esempio inviti che diventano validi solo a partire da una certa data. Configura una tolleranza di clock skew piccola, tra 30 e 60 secondi, per assorbire piccole differenze di orologio tra server senza aprire una finestra di replay troppo ampia.

Passo 6: Separare access token e refresh token con vite diverse

L’access token deve vivere poco, in genere tra 5 e 15 minuti, perché è quello che viaggia in ogni richiesta API e quindi ha la superficie di esposizione più ampia. Il refresh token vive più a lungo (ore o giorni), ma viene scambiato solo con l’endpoint di refresh, non con ogni chiamata API, riducendo le occasioni in cui può essere intercettato. Questa separazione è lo schema raccomandato sia da Auth0 sia da Okta nelle rispettive guide su autenticazione e gestione delle sessioni.

Il refresh token, a differenza dell’access token, conviene renderlo opaco (una stringa casuale registrata in un database) oppure, se resta un JWT, va sempre accompagnato da un record server-side che ne tracci lo stato. Solo così puoi revocarlo prima della scadenza naturale, cosa che un JWT stateless da solo non permette.

Passo 7: Implementare la rotazione dei refresh token con rilevamento del riuso

La rotazione funziona così: ogni volta che un client usa un refresh token, il server ne emette uno nuovo e invalida immediatamente quello appena usato. Se in futuro qualcuno prova a riutilizzare un refresh token già consumato, è un segnale forte di furto: qualcuno ha copiato il token, ma il client legittimo ha già effettuato il refresh successivo. In quel caso il server deve revocare l’intera catena di token associata a quella sessione, non solo l’ultimo.

async function ruotaRefreshToken(tokenRicevuto) {
  const record = await db.refreshTokens.findOne({ token: tokenRicevuto });

  if (!record) {
    throw new Error('Refresh token sconosciuto');
  }

  if (record.usato) {
    // riuso rilevato: probabile furto, revoca l'intera famiglia
    await db.refreshTokens.updateMany(
      { famiglia: record.famiglia },
      { revocato: true }
    );
    throw new Error('Riuso rilevato: sessione revocata');
  }

  await db.refreshTokens.updateOne(
    { token: tokenRicevuto },
    { usato: true }
  );

  const nuovoToken = crypto.randomUUID();
  await db.refreshTokens.insertOne({
    token: nuovoToken,
    famiglia: record.famiglia,
    utenteId: record.utenteId,
    creatoIl: new Date(),
    scadeIl: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
    usato: false,
  });

  return nuovoToken;
}

Il campo famiglia collega tutti i refresh token generati a partire dallo stesso login. Curity, nella sua documentazione tecnica sui pattern di autenticazione, descrive esattamente questo approccio a “catena” come base per distinguere un refresh legittimo da un tentativo di riutilizzo malevolo.

Passo 8: Revocare access token con una denylist su Redis

I JWT sono stateless per progettazione, il che significa che il server non deve consultare un database per verificarli. Il rovescio della medaglia è che non puoi invalidare un singolo token prima della sua scadenza senza tenere uno stato da qualche parte. La soluzione pratica è una denylist con TTL: memorizzi il jti dei token revocati (per esempio dopo un logout esplicito o un cambio password) e imposti la scadenza della voce su Redis uguale al tempo restante di validità del token, così la lista non cresce all’infinito.

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

async function revocaToken(jti, scadenzaUnix) {
  const secondiResidui = scadenzaUnix - Math.floor(Date.now() / 1000);
  if (secondiResidui > 0) {
    await redis.set(`revocato:${jti}`, '1', 'EX', secondiResidui);
  }
}

async function eRevocato(jti) {
  return (await redis.exists(`revocato:${jti}`)) === 1;
}

Nel middleware di verifica, dopo aver controllato firma e claim, aggiungi una lettura veloce su Redis per il jti. Il costo aggiuntivo è una lookup in memoria, dell’ordine del millisecondo, un compromesso ragionevole rispetto al rischio di non poter mai revocare un access token compromesso prima della scadenza naturale.

Passo 9: Scegliere dove salvare il token lato client

Dove il browser conserva il token determina quale classe di attacco diventa possibile. La tabella seguente riassume i compromessi principali per le applicazioni web, che restano il caso d’uso più comune per questa domanda.

Metodo di storageRischio XSSRischio CSRFQuando ha senso
Cookie HttpOnly + Secure + SameSiteBasso (JavaScript non può leggerlo)Presente, va mitigato con SameSite=Lax/Strict o token CSRF dedicatoApp web con backend che serve anche il frontend o dominio condiviso
localStorageAlto (qualsiasi script XSS può leggerlo)AssenteSconsigliato per token di sessione, accettabile solo per dati non sensibili
sessionStorageAlto (stesso problema di localStorage)AssenteRiduce solo la finestra temporale, non elimina il rischio XSS
Memoria applicativa (variabile JS, non persistita)Basso se combinato con refresh via cookie HttpOnlyAssenteSPA che rigenerano l’access token a ogni refresh della pagina

Per la maggior parte delle applicazioni web la combinazione più equilibrata resta: refresh token in un cookie HttpOnly, Secure e con attributo SameSite, access token tenuto solo in memoria lato client e mai scritto su storage persistente. Per app mobile native, usa il Keychain su iOS o il Keystore su Android invece di file di testo semplici, come raccomandano sia Auth0 sia Okta nelle loro guide per client mobile.

Sull’attributo SameSite vale la pena essere precisi, perché è un dettaglio da cui dipende se la mitigazione CSRF funziona davvero. SameSite=Strict blocca il cookie in ogni richiesta cross-site, incluso il click su un link esterno che porta al tuo sito, il che può rompere flussi legittimi come un redirect da un provider di pagamento. SameSite=Lax è il compromesso più comune: permette la navigazione diretta ma blocca le richieste POST cross-site, che sono il vettore tipico del CSRF. Riserva SameSite=None (che richiede sempre Secure) ai soli casi in cui hai davvero bisogno del cookie in un contesto cross-site, per esempio un widget embeddato su domini di terzi.

Passo 10: Proteggere le rotte con un middleware Express

Con le funzioni di firma e verifica pronte, il passo successivo è collegarle a un middleware che protegga le rotte dell’API. L’esempio seguente usa Express 5.2.1 e integra anche il controllo sulla denylist visto al passo 8.

function richiedeAutenticazione(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;

  if (!token) {
    return res.status(401).json({ errore: 'Token mancante' });
  }

  try {
    const payload = verificaAccessToken(token);

    eRevocato(payload.jti).then((revocato) => {
      if (revocato) {
        return res.status(401).json({ errore: 'Token revocato' });
      }
      req.utente = payload;
      next();
    });
  } catch (err) {
    return res.status(401).json({ errore: 'Token non valido', dettaglio: err.message });
  }
}

app.get('/api/profilo', richiedeAutenticazione, (req, res) => {
  res.json({ id: req.utente.sub, email: req.utente.email });
});

Se preferisci Fastify 5.12.5 invece di Express, il pattern è identico: un hook onRequest che esegue la stessa sequenza di verifica, controllo denylist e attach del payload alla request, sfruttando il sistema di decoratori nativo di Fastify per evitare di ripetere il controllo su ogni singola rotta.

Passo 11: Aggiungere rate limiting sugli endpoint di login e refresh

Un sistema di JWT tecnicamente perfetto resta vulnerabile se l’endpoint di login accetta tentativi illimitati. Il rate limiting non sostituisce la sicurezza del token, ma riduce drasticamente la finestra utile per attacchi di forza bruta contro le credenziali che, una volta indovinate, produrrebbero comunque un JWT legittimo.

import rateLimit from 'express-rate-limit';

const limitatoreLogin = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  standardHeaders: true,
  legacyHeaders: false,
  message: { errore: 'Troppi tentativi, riprova più tardi' },
});

app.post('/api/login', limitatoreLogin, gestisciLogin);
app.post('/api/refresh', limitatoreLogin, gestisciRefresh);

Dieci tentativi ogni 15 minuti per IP è un punto di partenza ragionevole, da affinare in base al traffico reale della tua applicazione. Valuta anche un backoff progressivo per account specifici, così da non penalizzare un intero ufficio dietro lo stesso NAT quando un solo utente sbaglia ripetutamente la password.

Passo 12: Testare il flusso completo e loggare gli eventi di sicurezza

Prima di andare in produzione, testa manualmente ogni scenario con curl: login riuscito, refresh valido, refresh riutilizzato (deve fallire e revocare la famiglia), token scaduto, token con audience sbagliata, token con algoritmo manomesso.

curl -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"segreta123"}'

# Output atteso:
# {"accessToken":"eyJhbGciOiJSUzI1NiIs...","refreshToken":"a1b2c3d4-..."}

curl http://localhost:3000/api/profilo \
  -H "Authorization: Bearer TOKEN_SCADUTO"

# Output atteso:
# {"errore":"Token non valido","dettaglio":"jwt expired"}

Registra ogni verifica fallita con il motivo (firma non valida, algoritmo non consentito, token scaduto, token revocato) in un log strutturato, non solo un generico errore 401. Sono proprio questi log, aggregati nel tempo, a farti notare pattern anomali come un singolo IP che genera centinaia di errori “algoritmo non consentito” in pochi minuti, indizio quasi certo di un tentativo di attacco automatizzato.

Errori comuni da evitare

  • Non specificare l’array algorithms in fase di verifica. È la causa diretta dell’attacco di alg confusion descritto al passo 4, e resta l’errore più frequente nelle code review che riguardano JWT.
  • Mettere dati sensibili nel payload. Il payload di un JWT è codificato in Base64URL, non cifrato: chiunque intercetti il token può leggerlo con un decoder online in due secondi. Non inserirci password, numeri di carta o dati sanitari.
  • Usare la stessa chiave per firmare access token e refresh token. Se la chiave dell’access token finisce in un log o in un errore di debug, un attaccante può forgiare anche refresh token validi.
  • Impostare una scadenza troppo lunga per comodità di sviluppo e dimenticarsene in produzione. Un access token valido 24 ore vanifica quasi tutti i benefici di un sistema di revoca ben progettato.
  • Fidarsi del claim exp senza validare anche iss e aud. Un token scaduto correttamente ma emesso per un altro servizio non dovrebbe comunque essere accettato.
  • Non gestire la rotazione delle chiavi RS256. Se la chiave privata viene compromessa, senza un piano di rotazione e un endpoint JWKS con più chiavi attive contemporaneamente, la sostituzione richiede di invalidare tutte le sessioni in un colpo solo.

Risoluzione dei problemi più frequenti

Anche con un’implementazione corretta, capitano errori in fase di sviluppo o dopo un deploy. Ecco gli otto problemi che si incontrano più spesso lavorando con jsonwebtoken e jose su Node.js.

  • Errore “invalid signature” dopo un deploy. Quasi sempre la chiave pubblica caricata dal servizio di verifica non corrisponde alla chiave privata usata per firmare, per esempio perché un ambiente di staging punta ancora alla vecchia coppia di chiavi.
  • Errore “jwt malformed”. Il token arriva troncato, spesso perché un proxy o un load balancer taglia gli header troppo lunghi. Controlla i limiti di dimensione degli header configurati su Nginx o sul tuo API gateway.
  • Errore “jwt expired” immediato, anche appena dopo il login. Controlla che gli orologi di sistema tra i server siano sincronizzati con NTP: uno sfasamento di alcuni minuti basta a far sembrare scaduto un token appena emesso.
  • “jwt audience invalid”. Il valore di audience passato a jwt.verify non combacia esattamente con quello usato in fase di firma, inclusi maiuscole, protocollo e slash finale nell’URL.
  • Il refresh token funziona una sola volta e poi tutte le richieste falliscono. È il comportamento corretto della rotazione descritta al passo 7: il client deve sempre salvare il nuovo refresh token restituito nella risposta, non riusare quello precedente.
  • La denylist su Redis cresce senza controllo. Verifica che il TTL passato a redis.set corrisponda al tempo residuo di validità del token e non a un valore fisso arbitrario, altrimenti le voci si accumulano oltre la scadenza naturale dei token.
  • Con jose l’import fallisce con errori legati a moduli ESM. La libreria richiede "type": "module" nel package.json oppure l’estensione .mjs per i file che la importano, a differenza di jsonwebtoken che supporta ancora CommonJS.
  • Le performance calano sotto carico per via della verifica RS256. La verifica con chiave pubblica RSA costa più CPU della verifica HMAC. Se il volume di richieste è molto alto, valuta ECDSA (algoritmo ES256), che offre garanzie simili con un costo computazionale inferiore.

Consigli avanzati per team con più microservizi

Quando più servizi devono verificare gli stessi token, distribuire manualmente public.pem a ogni deploy diventa presto insostenibile. La soluzione più comune è esporre un endpoint JWKS (/.well-known/jwks.json) dal server di autenticazione, e usare jwks-rsa nei servizi consumer per recuperare e mettere in cache la chiave pubblica corretta in base al kid (key ID) dichiarato nell’header del token.

import jwksClient from 'jwks-rsa';
import jwt from 'jsonwebtoken';

const client = jwksClient({
  jwksUri: 'https://api.tuosito.it/.well-known/jwks.json',
  cache: true,
  cacheMaxAge: 60 * 60 * 1000,
});

function recuperaChiave(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

jwt.verify(token, recuperaChiave, { algorithms: ['RS256'] }, (err, payload) => {
  // gestisci errore o payload verificato
});

Questo pattern permette anche di ruotare le chiavi senza tempi di fermo: pubblichi la nuova chiave pubblica nel JWKS mantenendo temporaneamente anche quella vecchia, inizi a firmare i nuovi token con la nuova chiave privata e, dopo che tutti i token firmati con la vecchia chiave sono naturalmente scaduti, la rimuovi dal JWKS. È lo stesso principio applicato dai grandi provider di identità come parte della normale manutenzione delle chiavi.

Un’altra raccomandazione avanzata riguarda il monitoraggio: traccia il tasso di errori “algoritmo non consentito” e “firma non valida” separatamente dagli errori “token scaduto”, perché i primi due sono indicatori molto più forti di un tentativo di attacco attivo contro il tuo endpoint di autenticazione, mentre il terzo è quasi sempre traffico legittimo con token vecchi.

Vulnerabilità note nell’ecosistema JOSE: cosa insegnano

Anche se non risultano CVE pubblicate nel 2025 o 2026 specifiche per i pacchetti npm jsonwebtoken, jose, express-jwt o jwks-rsa, altre implementazioni dello standard JOSE in ecosistemi diversi hanno subito problemi reali che vale la pena conoscere, perché la classe di bug si ripresenta ovunque si faccia parsing di token non fidati.

CVELibreria / ecosistemaTipo di impattoPunteggio CVSS
CVE-2025-53864Nimbus JOSE + JWT (Java)Denial of service via ricorsione incontrollata su claim JSON annidati in profonditàNon specificato nella fonte pubblica citata
CVE-2025-27144Go JOSE (Go, ramo 4.x prima di 4.0.5)Consumo eccessivo di memoria durante il parsing di JWS/JWE compatti7.5
CVE-2025-30204golang-jwt (Go)Difetto nell’implementazione dei JSON Web TokenNon specificato nella fonte pubblica citata

Il filo conduttore di questi tre casi è il parsing di input non fidato: un token arrivato da un client esterno che il server elabora prima ancora di aver verificato la firma. La lezione pratica per il codice Node.js di questa guida è validare la dimensione massima del token in ingresso (un limite di poche migliaia di caratteri è più che sufficiente per un JWT tipico) prima di passarlo alla libreria di parsing, così da ridurre la superficie di attacco anche verso bug di parsing non ancora scoperti.

Il progetto completo: struttura finale del servizio di autenticazione

Mettendo insieme tutti i passi precedenti, il servizio di autenticazione minimo ma production-ready ha questa struttura di cartelle:

jwt-sicuro-nodejs/
├── keys/
│   ├── private.pem
│   └── public.pem
├── src/
│   ├── auth/
│   │   ├── firma.js        # Passo 3: firma access token
│   │   ├── verifica.js      # Passo 4-5: verifica + validazione claim
│   │   ├── refresh.js       # Passo 7: rotazione refresh token
│   │   └── denylist.js      # Passo 8: revoca via Redis
│   ├── middleware/
│   │   ├── autenticazione.js  # Passo 10
│   │   └── rateLimiter.js     # Passo 11
│   ├── routes/
│   │   ├── login.js
│   │   ├── refresh.js
│   │   └── profilo.js
│   └── app.js
├── package.json
└── .env

Il file .env, escluso dal controllo versione, raccoglie i parametri sensibili che ogni ambiente (sviluppo, staging, produzione) valorizza in modo diverso:

PORT=3000
REDIS_URL=redis://localhost:6379
JWT_ISSUER=https://api.tuosito.it
JWT_AUDIENCE=https://tuosito.it
JWT_PRIVATE_KEY_PATH=./keys/private.pem
JWT_PUBLIC_KEY_PATH=./keys/public.pem
ACCESS_TOKEN_TTL=10m
REFRESH_TOKEN_TTL_GIORNI=7

Con questa organizzazione ogni file ha una responsabilità unica e testabile: puoi scrivere unit test per verifica.js passando token costruiti a mano con algoritmi sbagliati per confermare che vengano sempre rifiutati, senza dover avviare l’intero server Express a ogni test. È lo stesso approccio consigliato anche negli altri tutorial di sicurezza per Node.js, dove ogni meccanismo di difesa vive nel proprio modulo isolato.

Checklist finale prima del deploy in produzione

Prima di spostare questo servizio da un ambiente di test a un dominio pubblico, ripassa questi punti uno per uno. È la stessa lista che useremmo internamente in una code review dedicata a un endpoint di autenticazione basato su JWT.

  • La chiave privata RSA non è nel repository Git, nemmeno in un commit vecchio cancellato solo dall’ultimo commit (verifica con git log -p sull’intera storia, non solo sull’ultimo stato).
  • Il middleware di verifica passa sempre un array esplicito a algorithms, mai un valore dedotto o assente.
  • Access token con scadenza tra 5 e 15 minuti, refresh token con rotazione e rilevamento del riuso attivi.
  • Endpoint di login e refresh protetti da rate limiting, con soglie verificate sotto carico reale prima del lancio.
  • Denylist Redis raggiungibile con failover configurato: se Redis è irraggiungibile, decidi esplicitamente se il sistema deve negare o concedere l’accesso, non lasciarlo al comportamento di default della libreria.
  • Log strutturati per ogni causa di rifiuto del token, con alerting su picchi anomali di errori “algoritmo non consentito”.
  • TLS attivo su ogni endpoint che scambia token, senza eccezioni per ambienti interni o di staging.

Domande frequenti

HS256 è da considerare insicuro rispetto a RS256?

No, HS256 non è crittograficamente più debole di RS256 se usato correttamente con una chiave lunga e segreta. Il problema è operativo: con HS256 ogni servizio che verifica i token deve conoscere la stessa chiave usata per firmarli, il che allarga la superficie di rischio in architetture con più microservizi. RS256 separa chi firma da chi verifica, il che lo rende preferibile non per la forza della crittografia ma per il modello di distribuzione delle chiavi.

Quanto deve durare un access token JWT?

Tra 5 e 15 minuti è l’intervallo più comune nelle guide di Auth0 e Okta. Durate più brevi riducono la finestra di rischio in caso di furto, ma aumentano la frequenza delle chiamate all’endpoint di refresh, quindi il valore esatto va bilanciato in base al traffico e alla sensibilità dei dati esposti dalla tua API.

Posso semplicemente non usare i refresh token e allungare la durata dell’access token?

Tecnicamente sì, ma perdi la possibilità di revocare l’accesso in modo tempestivo senza una denylist che copra l’intera durata del token, e un token di lunga durata che finisce in un log o in un repository pubblico resta sfruttabile per giorni o settimane. Lo schema access token breve più refresh token ruotato resta la soluzione con il miglior compromesso tra sicurezza e usabilità.

La libreria jose sostituisce completamente jsonwebtoken?

Le due librerie coprono in gran parte lo stesso terreno, ma jose è costruita sulla Web Crypto API standard, supporta in modo nativo JWE (token cifrati, non solo firmati) e funziona sia in Node.js sia nel browser sia negli edge runtime. jsonwebtoken resta più diffusa in progetti Express classici e ha un’API leggermente più semplice per i casi d’uso base mostrati in questa guida.

Come revoco tutte le sessioni di un utente contemporaneamente, per esempio dopo un cambio password?

Aggiungi un claim tipo pwdChangedAt nel payload al momento della firma, o mantieni un contatore di versione per utente nel database. In fase di verifica, confronta il valore nel token con quello attuale salvato per l’utente: se non coincidono, rifiuta il token anche se firma e scadenza sono formalmente valide. In alternativa, revoca in blocco tutti i jti emessi per quell’utente nella finestra temporale precedente al cambio password, aggiungendoli alla denylist descritta al passo 8.

I JWT vanno bene anche per applicazioni mobile native, non solo per il web?

Sì, con un accorgimento sullo storage: su iOS usa il Keychain e su Android il Keystore per conservare access e refresh token, mai file di testo semplice o le SharedPreferences non cifrate. La logica di firma, verifica e rotazione lato server resta identica a quella descritta in questa guida, indipendentemente dal client che consuma l’API.

Serve TLS anche se i token sono già firmati?

Sì, sempre. La firma protegge dall’alterazione del contenuto, non dalla lettura. Un JWT trasmesso in chiaro su HTTP può essere intercettato e riutilizzato da chiunque veda il traffico di rete, anche se la firma è perfettamente valida e inalterata. TLS resta un prerequisito, non un’opzione, per qualsiasi endpoint che scambia token di autenticazione.