Autenticazione sul Web: Cookie, Sessioni e JWT Spiegati Davvero
Il problema: HTTP non ricorda chi sei
Una richiesta HTTP è autosufficiente: metodo, percorso, header, corpo. Arriva, viene servita, finisce lì. Il protocollo è stateless per progetto, e questo è un pregio: qualunque server del cluster può rispondere a qualunque richiesta senza sapere cosa è successo prima.
Il problema nasce quando serve sapere chi sta parlando. Se l'utente ha già inserito la password, il server deve riconoscerlo alla richiesta successiva, senza chiedergliela di nuovo a ogni click. Servono due cose:
- un identificativo che il client rimandi a ogni richiesta;
- un meccanismo che lo colleghi a un utente e a una scadenza.
Le soluzioni si dividono in due famiglie, e la differenza non è tecnica ma di filosofia: mettere lo stato sul server o sul client.
Sessioni: un identificativo opaco
Il flusso classico:
- il client invia le credenziali a un endpoint di login;
- il server le verifica e crea un record di sessione con un id casuale;
- risponde con un header che dice al browser di conservare quell'id;
- il browser lo rimanda automaticamente a ogni richiesta dello stesso dominio;
- il server cerca la sessione, controlla la scadenza e carica l'utente.
La risposta HTTP contiene qualcosa di questo tipo:
HTTP/1.1 204 No Content
Set-Cookie: session_id=9f2c...c41a; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
L'id è opaco: non contiene informazioni, non è decodificabile, non dice nulla sull'utente. È solo una chiave. Tutto il resto — ruolo, permessi, data di scadenza — vive nel database.
create table sessioni (
id text primary key,
utente_id bigint not null references utenti(id) on delete cascade,
creata_il timestamptz not null default now(),
scade_il timestamptz not null,
user_agent text
);
create index on sessioni (utente_id);
create index on sessioni (scade_il);
Il middleware che risolve l'utente è banale:
def utente_corrente(request):
sid = request.cookies.get("session_id")
if not sid:
return None
row = db.query_one(
"select utente_id from sessioni where id = %s and scade_il > now()",
(sid,),
)
if row is None:
return None
return carica_utente(row["utente_id"])
L'id deve essere generato con un CSPRNG e avere almeno 128 bit di entropia (esempio: 32 caratteri esadecimali casuali). Un id prevedibile significa che chiunque può indovinare la sessione di un altro: è session hijacking senza nemmeno bisogno di rubare nulla.
Gli attributi del cookie non sono decorazione
| Attributo | Cosa impedisce |
|---|---|
HttpOnly | JavaScript non legge il cookie: un XSS non può esfiltrare l'id di sessione |
Secure | Il cookie viaggia solo su HTTPS, non in chiaro su una connessione degradata |
SameSite=Lax | Il browser non lo invia su richieste cross-site non navigazionali: blocca la maggior parte dei CSRF |
SameSite=Strict | Nemmeno sui link in entrata dall'esterno: più sicuro, più scomodo |
Path=/ | Limita il cookie a un sottoalbero di URL |
Max-Age | Trasforma il cookie in persistente; senza, muore alla chiusura del browser |
Vantaggi delle sessioni: revoca immediata (cancelli la riga e l'utente è fuori), il client non sa nulla, nessun dato sensibile esposto.
Svantaggi: serve uno store condiviso tra le istanze, ogni richiesta fa un lookup, e su API consumate da app mobile o da servizi terzi il cookie diventa scomodo.
Token firmati: lo stato viaggia col client
Un JWT è una stringa composta da tre parti separate da punti: header, payload, firma. Le prime due sono JSON codificati in base64url, la terza è la firma del contenuto.
header.payload.firma
{"alg":"HS256","typ":"JWT"}.{"sub":"42","exp":1735689600}.<byte>
Il punto da fissare subito, perché è la fonte del novanta per cento degli equivoci: base64url non è cifratura. Chiunque può decodificare il payload e leggerlo. La firma garantisce l'integrità e l'autenticità del contenuto, non la riservatezza. Se metti il codice fiscale nel payload, è pubblico.
Verificare la firma è meccanico, e vale la pena scriverlo una volta per capire cosa succede sotto:
import base64, hashlib, hmac, json
def b64url_decode(s: str) -> bytes:
padding = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + padding)
def verifica(token: str, secret: str) -> dict:
header_b64, payload_b64, firma_b64 = token.split(".")
signing_input = f"{header_b64}.{payload_b64}".encode()
attesa = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
ricevuta = b64url_decode(firma_b64)
if not hmac.compare_digest(attesa, ricevuta):
raise ValueError("firma non valida")
return json.loads(b64url_decode(payload_b64))
Il codice è didattico: in produzione si usa una libreria collaudata, e si verifica anche exp, iss e aud. Ma mostra due dettagli che contano: la firma copre header e payload, quindi non puoi cambiare l'algoritmo né il contenuto; e il confronto va fatto con compare_digest, che non esce in anticipo sul primo byte diverso (altrimenti si apre la porta a un timing attack).
Access token e refresh token
Un access token con scadenza lunga è comodo e pericoloso: non si può revocare. La soluzione standard è spezzarlo in due.
| Access token | Refresh token | |
|---|---|---|
| Vita | Breve (esempio: 15 minuti) | Lunga (esempio: 30 giorni) |
| Dove sta | In memoria, o in cookie HttpOnly | Cookie HttpOnly, path ristretto |
| Scopo | Autorizzare le richieste | Ottenere un nuovo access token |
| Revoca | Solo per scadenza | Sì, lato server |
The refresh token va trattato come una credenziale a tutti gli effetti: salvato in un cookie HttpOnly, usato solo sull'endpoint di refresh, e ruotato a ogni utilizzo. Se un refresh token vecchio viene riusato, significa che qualcuno ne ha una copia: la reazione corretta è invalidare tutta la catena di quel dispositivo.
Il confronto onesto
| Criterio | Sessione server-side | JWT |
|---|---|---|
| Revoca immediata | Nativa | Richiede una denylist o token brevi |
| Costo per richiesta | Un lookup su DB/Redis | Solo verifica di firma, locale |
| Dimensione | Pochi byte | Centinaia di byte in ogni header |
| Multi-dominio / mobile | Scomodo | Naturale |
| Dipendenza da store condiviso | Sì | No |
| Dati nel token sempre freschi | Sì | No: restano quelli del momento dell'emissione |
| Superficie d'attacco tipica | CSRF | XSS + furto del token |
La riga che sorprende più spesso è l'ultima: entrambe le soluzioni sono soggette a entrambi i rischi, ma con pesi diversi. Un XSS con cookie HttpOnly non legge il token, ma può usare la sessione facendo richieste a nome dell'utente finché la pagina è compromessa. Un JWT in localStorage invece viene letto e portato via, e resta valido fino alla scadenza anche dopo che l'XSS è stato chiuso.
Gli errori che si vedono più spesso
- Token in
localStorage. Comodo, e fragile: qualunque script iniettato nella pagina legge la chiave. Con cookieHttpOnlyeSameSiteil JavaScript non lo tocca. - Fidarsi dell'header
alg. L'header è scritto dal client. Se il server si adegua al valore dichiarato, si aprono buchi storici (l'algoritmonone, o l'uso della chiave pubblica come segreto HMAC). L'algoritmo atteso va fissato lato server. - Confondere firma e cifratura. Dati sensibili nel payload sono leggibili da chiunque abbia il token in mano.
- Mettere i permessi nel token e aspettarsi che cambino. Se un utente viene declassato, il suo token continua a dire il contrario fino alla scadenza. Per questo gli access token sono brevi, o per questo si controlla lo stato sul server.
- Logout che cancella solo il token lato client. Se è un JWT non revocato, resta valido per chi lo ha copiato. Il logout vero richiede revoca o scadenza breve più refresh invalidato.
- Session fixation. Se l'id di sessione non viene rigenerato dopo il login, un attaccante che è riuscito a imporre un id noto si ritrova dentro l'account. Rigenerare sempre al cambio di privilegio.
- CSRF ignorato. Se l'autenticazione viaggia in un cookie, il browser lo allega anche alle richieste originate da un altro sito.
SameSite=Laxcopre la maggior parte dei casi; per operazioni cross-site legittime serveSameSite=None; Securepiù un token anti-CSRF esplicito. - Scadenze lunghe per non fare refresh. Il refresh esiste per questo. Un access token di trenta giorni è un access token che non puoi più ritirare.
Cosa scegliere, in pratica
Alcune euristiche che reggono bene:
- Applicazione web con un solo backend: sessioni server-side e cookie
HttpOnly+SameSite=Lax. Meno codice, revoca vera, nessun segreto nel browser. - API per mobile o client terzi: token, con access breve e refresh token rotante. Il cookie non è disponibile, quindi la gestione dello storage spetta al client.
- Più servizi che devono fidarsi l'uno dell'altro: token firmati emessi da un identity provider unico, con chiavi pubbliche e rotazione dei key set.
- Se ti serve revocare subito un token: ti serve comunque uno stato sul server. A quel punto conviene dirlo ad alta voce e progettarlo, invece di scoprirlo in incidente.
Vale la pena ripeterlo: la scelta non è "sicuro contro insicuro". È dove metti lo stato e quanto a lungo una credenziale resta valida senza controllo. Tutto il resto è conseguenza.
Conclusione
I punti che contano, in ordine sparso ma non casuale:
- HTTP non ha memoria: l'autenticazione è il meccanismo che gliela aggiunge, o sul server o nel client.
- La sessione è un id opaco; il JWT è una busta firmata ma leggibile. Firma non significa riservatezza.
- I cookie vanno sempre con
HttpOnly,SecureeSameSite. Sono tre parole che eliminano un'intera classe di attacchi. - Gli access token devono essere brevi, i refresh token lunghi, rotanti e revocabili.
- La revoca immediata richiede stato sul server: nessuna firma, per quanto solida, può annullare un token già emesso.
- Rigenera l'id di sessione al login, verifica
expealglato server, e non mettere in un token dati che devono restare aggiornati.
Foto di copertina di Mohammad Rahmani su Unsplash.