Un endpoint di login restituisce un JWT, e hai tre posti dove metterlo: un cookie, localStorage o sessionStorage. Scegli quello sbagliato e succede una di due cose: un bug XSS si porta via il token, oppure il token non arriva mai alla tua API. Tutti e tre salvano stringhe nel browser. Cambiano su chi può leggerle, quanto durano e se il server le vede o no.

localStorage e sessionStorage sono la stessa API, l’interfaccia Storage: setItem, getItem, removeItem, clear, chiavi e valori sempre stringa. La differenza è la durata. localStorage sopravvive alla chiusura del browser e al riavvio della macchina, e resta finché qualcosa non lo svuota. Safari è l’eccezione: con la tracking prevention attiva, elimina lo storage scritto da script per un sito con cui l’utente non ha interagito da sette giorni. sessionStorage appartiene a una singola tab e muore quando quella tab si chiude. Digiti lo stesso URL in una seconda tab e ottieni un sessionStorage nuovo e vuoto; solo “Duplica tab” lo copia.

I cookie sono più vecchi e funzionano diversamente. Il server ne imposta uno con l’header di risposta Set-Cookie, e da quel momento il browser lo allega automaticamente a ogni richiesta che corrisponde, nell’header Cookie. Questo vale anche per le richieste di immagini e fogli di stile, e per le fetch same-origin che non hai configurato per inviare nulla. localStorage e sessionStorage non escono mai dal browser a meno che il tuo JavaScript non li legga e li mandi da qualche parte.

Inviato automaticamente contro non inviato mai: questa singola proprietà risolve la maggior parte della domanda “quale uso”.

Limiti di storage e persistenza a confronto

CookielocalStoragesessionStorage
Inviato al serverSì, ogni richiesta che corrispondeNoNo
Capacità~4KB per cookie~5-10MB per origin (dipende dal browser)~5-10MB per origin
PersistenzaImpostata da Expires/Max-Age; cookie di sessione se omessoFinché non viene svuotato esplicitamenteFinché la tab non si chiude
ScopeDominio + path (configurabile)Origin (schema + host + porta)Origin, e solo la tab corrente
Leggibile da JSSì, a meno che non sia HttpOnlySempreSempre
APIHeader stringa, oppure document.cookieStorage (sincrona)Storage (sincrona)

Il divario di capacità è il motivo per cui carrelli e blob di impostazioni non finiscono nei cookie. 4KB si esauriscono in fretta appena salvi del JSON, e ogni byte viene ricaricato a ogni richiesta, che l’endpoint ne abbia bisogno o no. Una pagina che carica 40 asset statici dallo stesso dominio con un cookie da 3KB allegato invia quel cookie 40 volte.

Come leggere e scrivere ciascuno in JavaScript

localStorage e sessionStorage usano le stesse chiamate:

1
2
3
4
5
6
7
8
// localStorage: sopravvive alla chiusura del browser
localStorage.setItem("theme", "dark");
localStorage.getItem("theme");    // "dark"
localStorage.removeItem("theme");

// sessionStorage: sparisce quando questa tab si chiude
sessionStorage.setItem("checkout_step", "2");
sessionStorage.getItem("checkout_step"); // "2"

Entrambi salvano solo stringhe. setItem("user", {name: "Dan"}) salva la stringa letterale "[object Object]". Serializza prima:

1
2
localStorage.setItem("user", JSON.stringify({ name: "Dan", plan: "pro" }));
const user = JSON.parse(localStorage.getItem("user"));

Da JavaScript, un cookie passa per document.cookie:

1
document.cookie = "theme=dark; path=/; max-age=604800";

Sembra un’assegnazione che sovrascrive tutto. Non lo è. Ogni assegnazione imposta o aggiorna un singolo cookie, e leggere document.cookie restituisce tutti i cookie della pagina come un’unica stringa unita da ; . Devi spezzarla su ; e poi sul primo = da solo, oppure usare una piccola libreria che lo fa per te.

La maggior parte dei cookie viene impostata dal server, negli header di risposta. È anche l’unico modo per ottenere le protezioni che contano per qualsiasi cosa sensibile:

1
2
3
4
5
6
7
// Express
res.cookie("session_id", sessionToken, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 1000 * 60 * 60 * 24 * 7, // 7 giorni, in ms
});

Quella chiamata produce un header di risposta così:

1
Set-Cookie: session_id=a1b2c3...; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax
  • HttpOnly rimuove il cookie da document.cookie del tutto. Il JavaScript sulla pagina non può leggerlo, e questo vale anche per uno script iniettato da un attaccante. Per un token di sessione è il motivo principale per preferire un cookie a localStorage: un bug XSS che esegue JS arbitrario non riesce comunque a esfiltrare un cookie HttpOnly.
  • Secure impedisce al browser di inviare il cookie su HTTP semplice; esce solo via HTTPS. Impostalo su tutto in produzione. Guarda cosa fa TLS sotto HTTPS per capire quanto vale quella protezione.
  • SameSite controlla se il cookie esce sulle richieste cross-site. Strict lo invia solo su richieste same-site. Lax lo invia ancora sulla navigazione top-level GET, quindi seguire un link da un’email ti fa comunque entrare già autenticato, ma lo nega su POST cross-site e chiamate fetch. Questo blocca la maggior parte dei cross-site request forgery. None lo invia ovunque e richiede Secure, altrimenti il browser scarta il cookie direttamente. Chrome e Edge trattano un cookie senza SameSite come Lax; Firefox e Safari no, quindi impostalo esplicitamente.

localStorage e sessionStorage non hanno un equivalente di nessuna di queste protezioni. Qualunque cosa ci metti è leggibile da ogni script che gira sulla tua origin: il tuo codice, una dipendenza npm compromessa, il payload di un attaccante dopo un buco XSS. I cookie non sono più sicuri per natura. Hanno una modalità opt-in che il JavaScript non può toccare, e localStorage non l’ha. Ecco perché non dovresti mai tenere un token di autenticazione in localStorage.

Una pagina su app.example.com chiama api.example.com con fetch. Il cookie impostato da api.example.com resta a casa, anche con il domain giusto sul cookie, perché fetch invia le credenziali solo alla stessa origin per default. Devi fare l’opt-in:

1
2
3
fetch("https://api.example.com/profile", {
  credentials: "include",
});

Anche il server deve collaborare. Una volta che sono coinvolte le credenziali, non può rispondere con il wildcard Access-Control-Allow-Origin: *. Deve riflettere l’origin specifica e aggiungere Access-Control-Allow-Credentials: true. È la negoziazione CORS trattata in Cos’è CORS e Come Risolvere gli Errori CORS, ed è lì che vivono la maggior parte dei bug “il mio cookie non arriva”.

Come sincronizzare localStorage tra tab con l’evento storage

Quando localStorage cambia, il browser emette un evento storage in ogni altra tab sulla stessa origin, non in quella che ha fatto il cambiamento. Questo ti permette di disconnettere un utente ovunque nel momento in cui una tab chiama localStorage.removeItem("token"):

1
2
3
4
5
window.addEventListener("storage", (e) => {
  if (e.key === "token" && e.newValue === null) {
    window.location.href = "/login";
  }
});

sessionStorage non emette mai questo evento tra tab. Nessun’altra tab può vederlo.

I cookie sono sbagliati per qualsiasi cosa superi pochi kilobyte e per dati che il server non legge mai, come un tema dell’interfaccia o un commento a metà scritto. Li ricaricheresti a ogni richiesta per niente.

localStorage è sbagliato per qualsiasi cosa debba sopravvivere a un bug XSS: token di sessione, API key, qualsiasi cosa che non vorresti vedere in un report di una violazione con scritto “l’attaccante ha eseguito console.log(localStorage)”. È sbagliato anche per dati su cui agisce il server. Non esce mai dal browser a meno che il tuo codice non lo spedisca, e una volta scritto quel codice hai ricostruito un cookie senza HttpOnly, Secure o SameSite.

Con sessionStorage il problema è l’opposto: qualsiasi cosa condivisa tra tab, o che ti aspetti sia ancora lì dopo che l’utente chiude e riapre il browser, non ci deve stare. Si adatta a un solo tipo di problema: stato che appartiene a questa tab, per questa visita. Lo step corrente di un checkout multi-pagina è il caso classico, perché non deve finire in una seconda tab che l’utente ha aperto per confrontare i prezzi.

Oltre pochi megabyte, o quando ti servono query invece di lookup per chiave, nessuno dei tre va bene. Usa IndexedDB prima di provare a stipare qualche migliaio di record JSON in localStorage e sbattere contro la quota.

Quale usare per token, carrelli e preferenze

DatoMeccanismoPerché
Token di sessione, stato di autenticazioneCookie HttpOnly + SecureIl server ne ha bisogno; il JS non deve poterlo leggere
Token CSRFCookie (double-submit) o sessione server-sideDeve viaggiare andata e ritorno con la richiesta che protegge
Tema, lingua, preferenza UIlocalStoragePiccolo, non sensibile, deve persistere
Bozza di form multi-step, stato wizardsessionStoragePer tab, usa e getta, non serve round trip col server
Carrello (non autenticato)localStorage, o server-side legato a un cookie di sessioneDipende se deve sopravvivere a un browser svuotato
Dati strutturati grandi, cache offlineIndexedDBServe capacità e query che nessuno dei tre offre

Se il tuo server deve fidarsi di quel dato, mettilo in un cookie HttpOnly, Secure con SameSite esplicito. Tieni localStorage per i dati che saresti disposto a consegnare a qualsiasi script della tua pagina.

Articoli correlati