Disconnetti un utente da un’app basata su sessione e il server lo dimentica alla richiesta successiva. Disconnettilo da un’app basata su JWT e, a meno che tu non abbia costruito un meccanismo apposito, il token che ha in mano resta valido finché non scade. Questa è la vera decisione dietro “JWT vs sessione”. Il resto ne consegue.

Cosa memorizza davvero una sessione

Una sessione mantiene lo stato dell’utente sul server e al browser passa solo una chiave di lookup.

  1. L’utente effettua il login con una password.
  2. Il server crea un record di sessione (ID utente, ruoli, tutto ciò che serve) e lo salva in memoria, in un database o su Redis, indicizzato con un ID di sessione casuale.
  3. Il server invia quell’ID al browser come cookie, marcato HttpOnly così il JavaScript lato client non può leggerlo.
  4. A ogni richiesta, il browser rimanda automaticamente il cookie. Il server cerca l’ID nello store e recupera i dati della sessione.

Il cookie non porta nessuna informazione sull’utente, solo una stringa opaca che il server risolve a ogni richiesta. Elimina il record nello store e la sessione scompare alla richiesta successiva, su ogni dispositivo.

Cosa contiene un JWT

Un JSON Web Token evita il lookup mettendo lo stato direttamente nel token, firmato in modo che non possa essere manomesso.

Un JWT è composto da tre parti codificate in base64url e separate da punti: header.payload.signature. Il payload è un oggetto JSON di claim. L’RFC 7519 definisce un breve elenco di claim registrati:

ClaimNomeSignificato
issIssuerChi ha emesso il token
subSubjectA chi si riferisce il token
audAudienceA chi è destinato il token
expExpirationRifiuta il token dopo questo momento
nbfNot beforeRifiuta il token prima di questo momento
iatIssued atQuando è stato creato il token
jtiJWT IDID univoco, utile per revoca e controlli anti-replay

Il server firma header e payload con un secret (HMAC) o una chiave privata (RSA/ECDSA) e invia il risultato al client. Qualsiasi server che possieda la chiave corrispondente, o anche solo la chiave pubblica per la firma asimmetrica, può verificare la firma e fidarsi dei claim. Nessun round trip verso il database, nessun session store condiviso. In un sistema con più servizi, ciascuno può verificare un token per conto proprio.

Il rovescio della medaglia: chiunque possieda il token può leggere il payload. Base64 è una codifica, non una cifratura. Non inserire mai una password, un secret o dati personali che non stamperesti in un log dentro il payload di un JWT.

Come configurare l’autenticazione a sessione in Express

Una configurazione tipica in Node usa express-session come middleware e connect-redis perché le sessioni sopravvivano a un riavvio del server e funzionino su più istanze. Se Redis è nuovo per te, come funziona il caching con Redis copre le basi per farlo girare.

1
npm install express express-session connect-redis redis
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const express = require("express");
const session = require("express-session");
const { RedisStore } = require("connect-redis");
const { createClient } = require("redis");

const redisClient = createClient({ url: "redis://localhost:6379" });
redisClient.connect().catch(console.error);

const app = express();
app.use(express.json());

app.use(
  session({
    store: new RedisStore({ client: redisClient, prefix: "sess:" }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 1000 * 60 * 60 * 2, // 2 ore
    },
  })
);

app.post("/login", async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  req.session.userId = user.id;
  res.sendStatus(204);
});

app.get("/me", (req, res) => {
  if (!req.session.userId) return res.sendStatus(401);
  res.json({ userId: req.session.userId });
});

app.post("/logout", (req, res) => {
  req.session.destroy(() => res.sendStatus(204));
});

app.listen(3000);

req.session.destroy() rimuove la voce da Redis. La richiesta successiva con quel cookie non trova nulla e viene trattata come logout. Nessuna blacklist di token, niente da ritoccare dopo.

Come configurare l’autenticazione JWT in Node

Lo stesso flusso con jsonwebtoken, emettendo un access token di breve durata e un refresh token separato:

1
npm install express jsonwebtoken
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const crypto = require("crypto");
const express = require("express");
const jwt = require("jsonwebtoken");

const app = express();
app.use(express.json());

function issueTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: "15m", algorithm: "HS256" }
  );
  const refreshToken = jwt.sign(
    { sub: user.id, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: "7d", algorithm: "HS256" }
  );
  return { accessToken, refreshToken };
}

function requireAuth(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.sendStatus(401);

  try {
    req.user = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
      algorithms: ["HS256"],
    });
    next();
  } catch (err) {
    return res.sendStatus(401);
  }
}

app.post("/login", async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  res.json(issueTokens(user));
});

app.get("/me", requireAuth, (req, res) => {
  res.json({ userId: req.user.sub, role: req.user.role });
});

app.listen(3000);

jwt.verify controlla la firma e i claim exp/nbf in un’unica chiamata e lancia un’eccezione se uno dei due fallisce. Questo è l’intero controllo di autenticazione, senza alcuno store coinvolto.

L’opzione algorithms: ["HS256"] conta. Senza di essa, un token firmato con un algoritmo diverso da quello previsto potrebbe comunque passare, ed è così che diverse librerie JWT si sono ritrovate con vulnerabilità di “alg confusion”. Fissa nel codice l’elenco di algoritmi accettati in fase di verifica e rifiuta sempre alg: "none".

Il refresh token è ciò che rende vivibile un access token di 15 minuti. Il client lo conserva e, quando l’access token scade, lo scambia per una nuova coppia. Salva il jti del refresh token lato server così puoi revocare quella singola catena senza toccare gli access token già in circolazione.

Perché i JWT sono difficili da revocare

Una sessione è un record in uno store: eliminalo e l’utente viene disconnesso ovunque. Un JWT è un claim autosufficiente che qualsiasi verificatore accetta finché exp non dice il contrario. Il logout, il ban di un utente o il downgrade del suo ruolo non cambiano nulla per un token già emesso, perché il server che verifica non ha nessun record da eliminare.

Le mitigazioni più comuni:

  • Vita breve dell’access token. 15-30 minuti è l’intervallo più usato, così un token rubato o scaduto ha una finestra piccola.
  • Rotazione del refresh token. Ogni refresh emette un nuovo refresh token e invalida quello vecchio; riusare un refresh token vecchio revoca tutta la catena. OAuth 2.1 richiede questo meccanismo (o token sender-constrained) per i client pubblici.
  • Una denylist mirata. Per uccidere un token specifico prima che scada, indicizza la denylist sul jti più l’iss, non sulla stringa grezza del token. L’OWASP JWT Cheat Sheet spiega perché metterla sulla stringa stessa non è affidabile. Una denylist controllata a ogni richiesta rimette anche una query sul database a ogni richiesta, cioè esattamente il costo che i JWT dovevano eliminare.

Se la tua app finisce comunque per fare un lookup a ogni verifica, una sessione sarebbe stata la scelta più semplice.

Dove salvare un JWT nel browser

In un’app browser, metti il token in un cookie HttpOnly e Secure, non in localStorage. Qualsiasi script che gira sulla pagina può leggere localStorage, quindi un solo buco XSS consegna tutti i token che contiene. Il costo: una volta che il token viaggia in un cookie, il browser lo invia automaticamente, e ti ritrovi a difenderti dal CSRF con SameSite e token CSRF, esattamente come con le sessioni. Il confronto completo è in LocalStorage, SessionStorage o Cookie: Cosa Usare.

Quando usare le sessioni e quando usare i JWT

SituazioneUsa
Una sola app server-rendered, un solo backendSessioni: più semplici, revoca immediata
Logout istantaneo o cambi di permessi istantaneiSessioni, oppure JWT con una denylist su jti
Più servizi backend che verificano l’utente in autonomiaJWT: nessun session store condiviso da richiamare
App mobile o client API di terze partiJWT: i bearer token si adattano a un’API HTTP stateless

Nessuno dei due è più sicuro in astratto. Una sessione su Redis e un JWT di 15 minuti con refresh token ruotati tengono entrambi. La differenza è architetturale: quanti servizi devono fidarsi del claim e quanto in fretta devi poterlo revocare.

Con un solo backend, scegli le sessioni di default: la revoca è gratis. Passa ai JWT il giorno in cui un secondo servizio deve sapere chi è l’utente senza richiamare quello che l’ha autenticato.

Articoli correlati