Torna al blog
Autenticazione sul Web: Cookie, Sessioni e JWT Spiegati Davvero

Autenticazione sul Web: Cookie, Sessioni e JWT Spiegati Davvero

9 min di lettura1 visualizzazioni
di Marco Salmaso
HTTP non ricorda nulla: ogni richiesta arriva nuda. Vediamo come sessioni, cookie e token JWT risolvono il problema, quali sono i compromessi reali e quali errori si vedono più spesso in produzione.

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:

  1. il client invia le credenziali a un endpoint di login;
  2. il server le verifica e crea un record di sessione con un id casuale;
  3. risponde con un header che dice al browser di conservare quell'id;
  4. il browser lo rimanda automaticamente a ogni richiesta dello stesso dominio;
  5. 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

AttributoCosa impedisce
HttpOnlyJavaScript non legge il cookie: un XSS non può esfiltrare l'id di sessione
SecureIl cookie viaggia solo su HTTPS, non in chiaro su una connessione degradata
SameSite=LaxIl browser non lo invia su richieste cross-site non navigazionali: blocca la maggior parte dei CSRF
SameSite=StrictNemmeno sui link in entrata dall'esterno: più sicuro, più scomodo
Path=/Limita il cookie a un sottoalbero di URL
Max-AgeTrasforma 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 tokenRefresh token
VitaBreve (esempio: 15 minuti)Lunga (esempio: 30 giorni)
Dove staIn memoria, o in cookie HttpOnlyCookie HttpOnly, path ristretto
ScopoAutorizzare le richiesteOttenere un nuovo access token
RevocaSolo per scadenzaSì, 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

CriterioSessione server-sideJWT
Revoca immediataNativaRichiede una denylist o token brevi
Costo per richiestaUn lookup su DB/RedisSolo verifica di firma, locale
DimensionePochi byteCentinaia di byte in ogni header
Multi-dominio / mobileScomodoNaturale
Dipendenza da store condivisoSìNo
Dati nel token sempre freschiSìNo: restano quelli del momento dell'emissione
Superficie d'attacco tipicaCSRFXSS + 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 cookie HttpOnly e SameSite il 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'algoritmo none, 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=Lax copre la maggior parte dei casi; per operazioni cross-site legittime serve SameSite=None; Secure più 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, Secure e SameSite. 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 exp e alg lato server, e non mettere in un token dati che devono restare aggiornati.

Foto di copertina di Mohammad Rahmani su Unsplash.