L’autenticazione JWT è diventata lo standard de facto per proteggere le API REST scritte in Node.js. Un token firmato viaggia in ogni richiesta, il server lo verifica senza interrogare un database di sessioni e l’intera architettura resta stateless. Comodo, veloce, scalabile. E anche pieno di trappole: secreti deboli, algoritmo none, token che non scadono mai, refresh token salvati in localStorage e pronti per essere rubati da un attacco XSS.

Questo tutorial costruisce da zero un sistema completo di autenticazione JWT in Node.js, con Express 5, registrazione, login, access token e refresh token, rotazione dei refresh, revoca al logout, cookie httpOnly e middleware di verifica con allowlist degli algoritmi. Tutto il codice è pronto per essere copiato. Tempo stimato: circa 45 minuti. Alla fine avrai un’API REST funzionante e, soprattutto, capirai dove il 90% dei progetti sbaglia.

Perché l’autenticazione JWT domina le API Node.js nel 2026

Un JSON Web Token (JWT) è una stringa firmata crittograficamente che contiene informazioni sull’utente. Quando un client effettua il login, il server genera un token e glielo restituisce. Da quel momento il client allega il token a ogni richiesta e il server ne verifica la firma. Se la firma è valida, l’utente è autenticato. Nessuna ricerca in tabella, nessuno stato condiviso tra i nodi del backend.

Questa proprietà stateless è la ragione del successo del JWT nelle architetture moderne. Un sistema a microservizi con dieci istanze dietro un load balancer non deve sincronizzare le sessioni: ogni servizio verifica il token in autonomia usando una chiave pubblica. La stessa logica vale per le applicazioni mobile, dove i cookie tradizionali sono scomodi, e per i flussi API tra macchine. Il JWT è definito nello standard RFC 7519, pubblicato dalla IETF, ed è oggi supportato da ogni linguaggio e framework rilevante.

Il rovescio della medaglia è la sicurezza. Un token stateless resta valido fino alla scadenza, anche se l’utente fa logout o se l’account viene compromesso. Per questo un’implementazione seria non usa un solo token a vita lunga, ma combina un access token a vita breve (5-15 minuti) con un refresh token a vita lunga (7-30 giorni) e un meccanismo di rotazione e revoca. Tutto il tutorial ruota attorno a questo schema, che è quello raccomandato anche dal progetto OWASP Top Ten.

Come è fatto un JSON Web Token: header, payload e firma

Un JWT è composto da tre parti separate da un punto, codificate in Base64URL: header.payload.signature. Le prime due parti sono solo codificate, non cifrate, e chiunque può leggerle. Questo è il malinteso più diffuso: il payload di un JWT non è segreto. Non metterci mai password, numeri di carta o dati sensibili. La firma serve a garantire l’integrità, non la riservatezza.

  • Header: dichiara il tipo di token (JWT) e l’algoritmo di firma, per esempio {"alg":"RS256","typ":"JWT"}.
  • Payload: contiene le claim, cioè le affermazioni sull’utente e sul token (identità, scadenza, permessi).
  • Signature: è il risultato della firma crittografica di header e payload con la chiave segreta o privata. Solo chi possiede la chiave può produrla.

Le claim standard sono definite dalla RFC 7519 e ogni libreria seria le gestisce. Conoscerle evita di reinventare la ruota e di introdurre bug. Ecco le claim registrate più usate in un flusso di autenticazione.

ClaimNome estesoSignificatoEsempio
issIssuerChi ha emesso il tokenapi.miosito.it
subSubjectIdentità dell’utenteuser_8a3f
audAudienceDestinatario previstoapp-mobile
expExpirationTimestamp di scadenza1749650000
iatIssued AtQuando è stato emesso1749649100
jtiJWT IDIdentificatore univoco del token3f9a-…-c12b

La claim jti è la chiave di volta della revoca: assegnando a ogni refresh token un identificatore univoco possiamo invalidarlo singolarmente senza buttare via tutti gli altri. La useremo nello Step 7. Le claim exp e iat permettono invece alla libreria di rifiutare automaticamente i token scaduti, senza che tu debba scrivere una riga di logica temporale.

JWT contro sessioni server: quando scegliere i token

Prima di scrivere codice conviene capire se il JWT è davvero la scelta giusta. Le sessioni server tradizionali, con un identificatore opaco salvato in un cookie e lo stato in Redis o nel database, restano un’ottima opzione per molte applicazioni web monolitiche. La revoca è immediata e il cookie non espone alcun dato. Il JWT vince quando lo stato condiviso è un problema: API pubbliche, microservizi, app mobile, autenticazione tra server.

CriterioSessione serverJWT (access + refresh)
Stato sul serverSì, per ogni sessioneNo per l’access, sì solo per i refresh
Revoca immediataBanaleRichiede allowlist o jti store
Scalabilità orizzontaleServe store condivisoNativa per l’access token
App mobile e APIScomodaIdeale
Esposizione dati nel clientNessunaPayload leggibile in chiaro
Complessità implementativaBassaMedia o alta

La regola pratica: se hai una singola applicazione web e non prevedi client esterni, le sessioni server sono più semplici e più sicure per default. Se invece servi più client eterogenei o scali su molti nodi, il JWT ripaga la complessità aggiuntiva. Questo tutorial adotta lo schema ibrido più solido, che mantiene i refresh token in uno store revocabile pur lasciando gli access token completamente stateless.

Prerequisiti e versioni dei pacchetti

Il progetto usa Node.js e un piccolo gruppo di librerie consolidate. Le versioni indicate sono quelle stabili al giugno 2026. Fissa le versioni nel tuo package.json e aggiorna regolarmente: mantenere le dipendenze aggiornate è una delle poche difese reali contro le vulnerabilità delle librerie JWT.

Strumento / pacchettoVersione consigliataRuolo nel progetto
Node.js20 LTS o 22 LTSRuntime JavaScript
npm10 o superioreGestore pacchetti
express5.2.xFramework HTTP
jsonwebtoken9.0.3Firma e verifica dei JWT
jose6.2.xAlternativa moderna (JOSE)
bcrypt6.0.0Hashing delle password
cookie-parser1.4.xLettura dei cookie httpOnly
dotenv16.xVariabili d’ambiente

Verifica la versione di Node prima di iniziare. Se usi una versione precedente alla 18, aggiorna: Express 5 e le API crittografiche moderne richiedono un runtime recente.

node -v
# atteso: v20.x.x oppure v22.x.x

npm -v
# atteso: 10.x.x o superiore

Step 1 e 2: inizializzare il progetto Node.js ed Express

Crea la cartella del progetto, inizializza npm e installa le dipendenze. Usiamo i moduli ECMAScript ("type": "module") per scrivere import moderni invece di require.

mkdir api-jwt && cd api-jwt
npm init -y
npm install express@5 jsonwebtoken@9 bcrypt@6 cookie-parser dotenv
npm pkg set type=module

Struttura le cartelle in modo che ogni responsabilità abbia il suo file. Una buona organizzazione fin dall’inizio rende il codice di autenticazione più facile da revisionare, e la revisione è proprio dove si trovano i bug di sicurezza.

api-jwt/
  keys/                 # chiavi RS256 (mai nel repository)
  src/
    config.js           # caricamento variabili e chiavi
    store.js            # utenti e refresh token (in memoria, demo)
    tokens.js           # firma e verifica dei JWT
    middleware.js       # protezione delle rotte
    routes.js           # register, login, refresh, logout
    server.js           # avvio dell'app Express
  .env
  package.json

Crea il file src/server.js con lo scheletro dell’applicazione. Aggiungiamo subito cookie-parser, che serve per leggere i refresh token salvati nei cookie httpOnly, e un middleware JSON per il corpo delle richieste.

// src/server.js
import express from 'express';
import cookieParser from 'cookie-parser';
import { router } from './routes.js';

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/auth', router);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API in ascolto sulla porta ${PORT}`));

Step 3: generare le chiavi RS256 e configurare l’ambiente

Qui prendiamo la prima decisione di sicurezza importante: l’algoritmo di firma. HS256 usa un solo segreto condiviso sia per firmare sia per verificare. Va bene per un singolo servizio, ma in un’architettura distribuita ogni servizio che verifica avrebbe bisogno del segreto, e quindi potrebbe anche firmare token falsi. RS256 risolve il problema: il server di autenticazione firma con una chiave privata, tutti gli altri verificano con la chiave pubblica. La chiave privata non lascia mai il server di auth.

Generiamo una coppia di chiavi RSA a 2048 bit con OpenSSL. La chiave privata firma, la pubblica verifica.

mkdir keys
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
# aggiungi keys/ al .gitignore: le chiavi non vanno mai versionate
echo "keys/" >> .gitignore
echo ".env"  >> .gitignore

Crea il file .env con i parametri di durata e gli identificatori. Tenere questi valori fuori dal codice permette di cambiarli per ambiente (sviluppo, staging, produzione) senza ricompilare nulla.

# .env
PORT=3000
JWT_ISSUER=api.miosito.it
JWT_AUDIENCE=app-web
ACCESS_TTL=15m
REFRESH_TTL=7d
NODE_ENV=development

Il file src/config.js carica le variabili e legge le chiavi dal disco una sola volta all’avvio. Leggere le chiavi a ogni richiesta sarebbe uno spreco e un rischio.

// src/config.js
import 'dotenv/config';
import { readFileSync } from 'node:fs';

export const config = {
  privateKey: readFileSync('keys/private.pem'),
  publicKey:  readFileSync('keys/public.pem'),
  issuer:   process.env.JWT_ISSUER,
  audience: process.env.JWT_AUDIENCE,
  accessTtl:  process.env.ACCESS_TTL  || '15m',
  refreshTtl: process.env.REFRESH_TTL || '7d',
  isProd: process.env.NODE_ENV === 'production',
};

Step 4: registrazione utente con hashing bcrypt

Le password non si salvano mai in chiaro e non si firmano dentro un JWT. Si trasformano in un hash con un algoritmo lento e salato come bcrypt. Per la demo teniamo gli utenti in memoria, in un file store.js. In produzione qui ci sarebbe il tuo database. Se vuoi approfondire il perché di bcrypt e dei suoi parametri, leggi la nostra guida alla sicurezza delle password.

// src/store.js
// Store in memoria per la demo. In produzione: database reale.
export const users = new Map();          // email -> { id, email, hash }
export const refreshStore = new Map();   // jti   -> { userId, expiresAt }

export function findUser(email) {
  return users.get(email);
}

La rotta di registrazione valida l’input, controlla che l’email non esista già, calcola l’hash della password con un costo di 12 e crea l’utente. Il costo 12 è un buon compromesso tra sicurezza e latenza nel 2026; valori più alti rallentano il login senza un vantaggio proporzionale per la maggior parte delle applicazioni.

// src/routes.js (estratto: registrazione)
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { randomUUID } from 'node:crypto';
import { users, findUser } from './store.js';

export const router = Router();

router.post('/register', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password || password.length < 10) {
    return res.status(400).json({ error: 'Email valida e password di almeno 10 caratteri' });
  }
  if (findUser(email)) {
    return res.status(409).json({ error: 'Utente già registrato' });
  }
  const hash = await bcrypt.hash(password, 12);
  const user = { id: randomUUID(), email, hash };
  users.set(email, user);
  return res.status(201).json({ id: user.id, email: user.email });
});

Step 5: login e generazione di access e refresh token

Centralizziamo la firma dei token in src/tokens.js. L’access token porta l’identità e i permessi e dura 15 minuti. Il refresh token porta solo un jti univoco, dura 7 giorni e viene registrato nel refreshStore in modo da poterlo revocare. Notare l’uso esplicito di algorithm: 'RS256', di issuer e audience: sono questi vincoli a rendere la verifica robusta nello step successivo.

// src/tokens.js
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
import { config } from './config.js';
import { refreshStore } from './store.js';

export function signAccess(user) {
  return jwt.sign(
    { email: user.email, role: 'user' },
    config.privateKey,
    {
      algorithm: 'RS256',
      subject: user.id,
      issuer: config.issuer,
      audience: config.audience,
      expiresIn: config.accessTtl,
    }
  );
}

export function signRefresh(user) {
  const jti = randomUUID();
  const token = jwt.sign({}, config.privateKey, {
    algorithm: 'RS256',
    subject: user.id,
    jwtid: jti,
    issuer: config.issuer,
    audience: config.audience,
    expiresIn: config.refreshTtl,
  });
  const ms = 7 * 24 * 60 * 60 * 1000;
  refreshStore.set(jti, { userId: user.id, expiresAt: Date.now() + ms });
  return token;
}

La rotta di login verifica la password con bcrypt.compare, genera la coppia di token e restituisce l’access token nel corpo JSON mentre invia il refresh token in un cookie httpOnly. Importante: rispondi con lo stesso messaggio di errore generico sia quando l’email non esiste sia quando la password è sbagliata, per non rivelare quali email sono registrate.

// src/routes.js (estratto: login)
import bcrypt from 'bcrypt';
import { signAccess, signRefresh } from './tokens.js';
import { config } from './config.js';

router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = findUser(email);
  const ok = user && await bcrypt.compare(password, user.hash);
  if (!ok) {
    return res.status(401).json({ error: 'Credenziali non valide' });
  }
  const accessToken  = signAccess(user);
  const refreshToken = signRefresh(user);

  res.cookie('refresh', refreshToken, {
    httpOnly: true,
    secure: config.isProd,
    sameSite: 'strict',
    path: '/auth',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
  return res.json({ accessToken });
});

Step 6: middleware di verifica con allowlist degli algoritmi

Questo è lo step più importante per la sicurezza. La verifica deve sempre passare un elenco esplicito di algoritmi accettati a jwt.verify. Senza questo vincolo, un attaccante può modificare l’header del token e impostare alg: none oppure passare da RS256 a HS256 (confusione di algoritmo), inducendo il verificatore a trattare la chiave pubblica come segreto HMAC. Sono due classi di vulnerabilità note da anni e ancora frequenti nel codice reale.

// src/middleware.js
import jwt from 'jsonwebtoken';
import { config } from './config.js';

export function requireAuth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) {
    return res.status(401).json({ error: 'Token mancante' });
  }
  try {
    const payload = jwt.verify(token, config.publicKey, {
      algorithms: ['RS256'],        // allowlist esplicita: blocca none e HS256
      issuer: config.issuer,
      audience: config.audience,
    });
    req.user = { id: payload.sub, email: payload.email, role: payload.role };
    return next();
  } catch (err) {
    return res.status(401).json({ error: 'Token non valido o scaduto' });
  }
}

Ora puoi proteggere qualsiasi rotta semplicemente anteponendo requireAuth. La libreria controlla automaticamente firma, scadenza, issuer e audience: se uno solo di questi non torna, la richiesta viene respinta con 401.

// src/routes.js (estratto: rotta protetta)
import { requireAuth } from './middleware.js';

router.get('/me', requireAuth, (req, res) => {
  res.json({ id: req.user.id, email: req.user.email, role: req.user.role });
});

Step 7: rotazione dei refresh token con archivio jti

La rotazione dei refresh token è oggi una pratica standard. Quando il client usa un refresh token per ottenere un nuovo access token, il server emette anche un nuovo refresh token e invalida immediatamente quello vecchio. Se un attaccante ruba un refresh token e lo usa, alla volta successiva in cui l’utente legittimo prova a rinnovare scoprirà che il suo token è stato già consumato: il furto diventa rilevabile.

// src/routes.js (estratto: refresh con rotazione)
import jwt from 'jsonwebtoken';
import { refreshStore, users } from './store.js';

router.post('/refresh', (req, res) => {
  const token = req.cookies?.refresh;
  if (!token) return res.status(401).json({ error: 'Refresh mancante' });

  let payload;
  try {
    payload = jwt.verify(token, config.publicKey, {
      algorithms: ['RS256'],
      issuer: config.issuer,
      audience: config.audience,
    });
  } catch {
    return res.status(401).json({ error: 'Refresh non valido' });
  }

  // il jti deve esistere ancora nello store: se manca è già stato ruotato o revocato
  if (!refreshStore.has(payload.jti)) {
    return res.status(401).json({ error: 'Refresh revocato' });
  }
  refreshStore.delete(payload.jti);   // invalida il vecchio refresh

  const user = [...users.values()].find(u => u.id === payload.sub);
  if (!user) return res.status(401).json({ error: 'Utente assente' });

  const accessToken  = signAccess(user);
  const refreshToken = signRefresh(user);   // nuovo jti registrato
  res.cookie('refresh', refreshToken, {
    httpOnly: true, secure: config.isProd, sameSite: 'strict',
    path: '/auth', maxAge: 7 * 24 * 60 * 60 * 1000,
  });
  return res.json({ accessToken });
});

In produzione il refreshStore non vive in memoria ma in Redis o nel database, così sopravvive ai riavvii e funziona su più nodi. Un campo con scadenza (TTL) in Redis ripulisce automaticamente i token scaduti.

Step 8: logout e revoca dei token

Poiché l’access token è stateless, non può essere revocato direttamente: resta valido fino alla scadenza. Per questo lo teniamo a vita breve. Il logout, invece, agisce sul refresh token, che è quello revocabile. Rimuoviamo il suo jti dallo store e cancelliamo il cookie. Dopo al massimo 15 minuti, quando l’access scade, l’utente è del tutto disconnesso.

// src/routes.js (estratto: logout)
router.post('/logout', (req, res) => {
  const token = req.cookies?.refresh;
  if (token) {
    try {
      const payload = jwt.verify(token, config.publicKey, {
        algorithms: ['RS256'], issuer: config.issuer, audience: config.audience,
      });
      refreshStore.delete(payload.jti);   // revoca server-side
    } catch { /* token già scaduto o invalido: ignora */ }
  }
  res.clearCookie('refresh', { path: '/auth' });
  return res.status(204).end();
});

Se ti serve un logout immediato anche per gli access token, per esempio dopo un cambio password o la compromissione di un account, aggiungi una denylist dei jti degli access oppure un campo tokenVersion nell’utente da confrontare nel middleware. È un costo in termini di stato, ma è l’unico modo di invalidare un access token prima della scadenza.

Dove conservare i token nel browser è una delle domande più dibattute e una delle più sbagliate nella pratica. Salvare i token in localStorage è comodo ma pericoloso: qualsiasi script malevolo iniettato tramite XSS può leggerli e rubarli. La raccomandazione attuale, allineata alle linee guida OWASP, è tenere il refresh token in un cookie httpOnly, Secure e SameSite, irraggiungibile da JavaScript, e mantenere l’access token in memoria (una variabile JavaScript), non in storage persistente.

  • httpOnly: il cookie non è leggibile da document.cookie, quindi è protetto dall’XSS.
  • Secure: il cookie viaggia solo su HTTPS. Per approfondire il ruolo del TLS leggi HTTPS e TLS.
  • SameSite=Strict o Lax: limita l’invio del cookie alle richieste cross-site, mitigando il CSRF.
  • Token in memoria: l’access token sparisce al refresh della pagina, e va riottenuto via /refresh. È un comportamento voluto.

Poiché usiamo cookie per il refresh, dobbiamo difenderci dal CSRF. SameSite=Strict blocca la maggior parte degli attacchi, ma per sicurezza aggiungi un token anti-CSRF sulle rotte sensibili e applica un rate limiting a login e refresh per frenare il brute force. Un middleware come express-rate-limit basta per partire.

// esempio: rate limiting su login e refresh
import rateLimit from 'express-rate-limit';

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minuti
  max: 20,                   // max 20 tentativi per IP
  standardHeaders: true,
  message: { error: 'Troppi tentativi, riprova più tardi' },
});

router.post('/login',   authLimiter, /* ...handler... */);
router.post('/refresh', authLimiter, /* ...handler... */);

Step 11 e 12: testare l’API con esempi di output

Avvia il server e prova il flusso completo con curl. Prima registriamo un utente, poi facciamo login, infine chiamiamo una rotta protetta con l’access token ottenuto.

node src/server.js
# API in ascolto sulla porta 3000

# 1) registrazione
curl -s -X POST http://localhost:3000/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"PasswordSicura2026"}'
# {"id":"7b1d...","email":"[email protected]"}

# 2) login (salva i cookie in cookies.txt)
curl -s -c cookies.txt -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"PasswordSicura2026"}'
# {"accessToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."}

Copia l’access token e chiama la rotta protetta. Poi prova a rinnovarlo usando il cookie salvato.

# 3) rotta protetta
curl -s http://localhost:3000/auth/me \
  -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiI...'
# {"id":"7b1d...","email":"[email protected]","role":"user"}

# 4) rinnovo con rotazione (usa il cookie refresh)
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:3000/auth/refresh
# {"accessToken":"eyJhbGciOiJSUzI1Ni..."}  (nuovo access + nuovo refresh nel cookie)

# 5) chiamata senza token
curl -s http://localhost:3000/auth/me
# {"error":"Token mancante"}

Se vedi questi output, l’autenticazione JWT funziona end-to-end: registrazione, login, accesso protetto, rotazione e rifiuto delle richieste non autenticate. Il sistema è completo e pronto per essere collegato a un database reale.

6 errori comuni nell’autenticazione JWT da evitare

La maggior parte delle falle nei sistemi JWT non nasce dalla crittografia, ma da scelte implementative sbagliate. Ecco i sei errori che ritornano più spesso nelle revisioni di sicurezza.

  • Non specificare gli algoritmi in verify. Senza algorithms: ['RS256'] sei esposto agli attacchi alg: none e alla confusione di algoritmo. È l’errore numero uno.
  • Segreti HS256 deboli. Una chiave come secret123 si rompe a forza bruta in pochi minuti. Se usi HS256, genera almeno 256 bit casuali e usa segreti diversi per access e refresh.
  • Mettere dati sensibili nel payload. Il payload è leggibile da chiunque. Niente password, token di terze parti o dati personali oltre il necessario.
  • Access token a vita lunga. Un token valido 30 giorni che non puoi revocare è una bomba a orologeria. Tienili a 5-15 minuti.
  • Refresh token in localStorage. Espone i token all’XSS. Usa sempre cookie httpOnly.
  • Nessuna rotazione né revoca. Senza jti store non puoi disconnettere un utente né rilevare un furto di refresh token.

Un settimo errore, più sottile, riguarda l’uso del JWT per le sessioni di una semplice app web monolitica: spesso una sessione server tradizionale sarebbe più sicura e più semplice. Scegli il JWT quando ti serve davvero la natura stateless, non perché è di moda.

Risoluzione dei problemi: 8 errori frequenti

Durante l’implementazione incontrerai quasi certamente uno di questi messaggi. La tabella mappa l’errore alla causa e alla soluzione.

ErroreCausa probabileSoluzione
invalid signatureChiave pubblica non corrispondente alla privataRigenera la coppia e verifica i percorsi in config.js
jwt expiredAccess token scaduto (normale dopo 15 min)Chiama /refresh per ottenerne uno nuovo
jwt audience invalidaud nel token diverso da quello attesoAllinea JWT_AUDIENCE tra firma e verifica
invalid algorithmAlgoritmo del token fuori dall’allowlistVerifica che firma e verify usino RS256
secretOrPrivateKey must be...Chiave non letta o percorso erratoControlla readFileSync e l’esistenza dei file in keys/
Refresh revocatojti già ruotato o store riavviatoRifai login; in produzione usa Redis persistente
Cookie non inviatoSameSite o dominio/percorso erratiVerifica path ‘/auth’ e secure in HTTPS
CORS blocca le richiestecredentials non abilitati nel browserImposta cors con credentials: true e origin esplicito

Se l’errore non rientra in questa lista, abilita un log temporaneo del payload decodificato (senza dati sensibili) con jwt.decode(token) per vedere claim, scadenza e algoritmo effettivi. Spesso il problema è un disallineamento banale tra issuer o audience.

Consigli avanzati per la produzione

Il progetto del tutorial è solido come base, ma in produzione conviene aggiungere qualche strato in più. Questi accorgimenti distinguono un’implementazione didattica da una che regge il traffico reale e gli audit di sicurezza.

  • Rotazione delle chiavi (key rotation). Pubblica più chiavi tramite un endpoint JWKS e identifica la chiave attiva con la claim kid nell’header. Così puoi ruotare la chiave di firma senza invalidare tutti i token.
  • Libreria jose per casi avanzati. Per algoritmi a curva ellittica come EdDSA, per JWE (token cifrati) o per la validazione JWKS, la libreria jose è più completa di jsonwebtoken.
  • Store persistente per i refresh. Sposta refreshStore su Redis con TTL nativo: revoca distribuita, pulizia automatica e resilienza ai riavvii.
  • Audience multiple. Se servi più client (web, mobile, partner), emetti token con audience distinte e verifica quella corretta in ogni servizio.
  • Monitoraggio. Registra i tentativi di refresh con jti già consumato: sono il segnale più affidabile di un furto di token in corso.
  • Header di sicurezza. Aggiungi helmet per impostare automaticamente HSTS, X-Content-Type-Options e Content-Security-Policy, riducendo la superficie XSS.

La firma RS256 si appoggia alla solidità di RSA, una primitiva che condivide le radici matematiche con le firme digitali e con le funzioni hash crittografiche. Capire questi mattoni rende molto più intuitivo il comportamento dei JWT e aiuta a diagnosticare i problemi quando qualcosa non torna.

JWT e l’orizzonte post-quantistico

Una domanda legittima nel 2026: gli algoritmi di firma dei JWT reggeranno l’avvento dei computer quantistici? RS256 ed EdDSA si basano su problemi matematici che un computer quantistico abbastanza potente potrebbe risolvere con l’algoritmo di Shor. La minaccia non è immediata, ma le organizzazioni che firmano token a lunga durata o che devono garantire la validità nel tempo dovrebbero iniziare a pianificare la migrazione verso algoritmi resistenti ai quanti.

Il NIST ha già standardizzato i primi algoritmi post-quantistici e l’ecosistema JOSE sta valutando come integrarli nelle firme dei token. Per la maggior parte delle applicazioni, dove un access token vive 15 minuti e un refresh token sette giorni, il rischio quantistico è oggi trascurabile: la finestra di validità è troppo breve perché un attacco “raccogli ora, decifra dopo” abbia senso. Tieni comunque d’occhio l’evoluzione delle librerie e mantieni il codice di firma centralizzato in un solo modulo, come abbiamo fatto in tokens.js, proprio per rendere indolore una futura migrazione di algoritmo.

Domande frequenti sull’autenticazione JWT

Il JWT è cifrato?

No. Un JWT standard (JWS) è firmato, non cifrato. Header e payload sono solo codificati in Base64URL e chiunque può leggerli. La firma garantisce che il contenuto non sia stato alterato, non che sia segreto. Per cifrare davvero il contenuto serve JWE, una variante meno comune. La regola resta: non mettere dati sensibili nel payload.

Meglio HS256 o RS256?

Per un singolo servizio, HS256 con un segreto forte (almeno 256 bit casuali) è accettabile e più semplice. Per più servizi o quando la verifica avviene su nodi diversi da quello che firma, RS256 è la scelta corretta: la chiave privata firma e resta sul server di auth, la chiave pubblica verifica ovunque senza poter falsificare token.

Quanto deve durare un access token?

La raccomandazione corrente è tra 5 e 15 minuti. Una durata breve limita il danno se il token viene rubato, perché diventa inutile in fretta. La continuità dell’esperienza utente è garantita dal refresh token, che rinnova l’access in modo trasparente senza chiedere di nuovo le credenziali.

Dove conservo i token nel browser?

Refresh token in un cookie httpOnly, Secure e SameSite, irraggiungibile da JavaScript. Access token in memoria, in una variabile dell’applicazione. Evita localStorage e sessionStorage per entrambi: sono leggibili da qualsiasi script e quindi vulnerabili all’XSS.

Come faccio logout se il JWT è stateless?

Revochi il refresh token, cancellando il suo jti dallo store e rimuovendo il cookie. L’access token resta valido fino alla scadenza (per questo lo tieni a 15 minuti). Per un logout istantaneo anche degli access token, aggiungi una denylist dei jti o un campo tokenVersion da controllare nel middleware.

jsonwebtoken o jose: quale libreria uso?

Per la maggior parte delle applicazioni Node.js, jsonwebtoken (versione 9.0.3) è la scelta più diretta e documentata. Se ti servono algoritmi a curva ellittica come EdDSA, token cifrati (JWE) o la validazione di endpoint JWKS, passa a jose (versione 6.2.x), più moderna e completa. Le due possono anche convivere nello stesso progetto.

Cos’è l’attacco “alg: none”?

È un attacco classico: l’aggressore modifica l’header del token impostando alg: none, che nello standard indica un token senza firma. Se il verificatore non controlla l’algoritmo, accetta il token come valido pur senza firma, permettendo di falsificare qualsiasi identità. La difesa è sempre la stessa: passare un’allowlist esplicita di algoritmi a jwt.verify, come fatto nello Step 6.

Posso usare lo stesso schema per un’app mobile?

Sì, ed è uno dei casi d’uso ideali del JWT. Su mobile, dove i cookie sono scomodi, conserva il refresh token nello storage sicuro del dispositivo (Keychain su iOS, Keystore su Android) anziché in un cookie, e mantieni l’access token in memoria. Lo schema di rotazione e revoca resta identico a quello mostrato qui.

Fonti e approfondimenti esterni: lo standard RFC 7519 (JSON Web Token), il debugger e la documentazione su jwt.io, la OWASP JWT Cheat Sheet, il repository ufficiale di jsonwebtoken e quello di jose.