Torna al blog

2026-05-26

Session e refresh token in SPA: pattern e errori comuni

Storage, rotazione e minacce realistiche quando una SPA consuma API con cookie o bearer token.

Una SPA (React, Vue, Svelte…) che parla con la tua API deve decidere dove tenere access token brevi e refresh token lunghi. Le slide dicono “usa cookie HttpOnly”; in pratica molte app mettono JWT in localStorage per comodità—ed espongono XSS long-lived.

Questo articolo è framework-agnostico; in codice puoi applicarlo con Auth.js, Lucia, better-auth o session server-side custom—the point è il modello di minaccia, non il brand.

Minacce principali

  • XSS: script malevolo nel DOM legge localStorage / sessionStorage. Gli HttpOnly cookie non sono leggibili da JS → preferiti per refresh (e spesso session id).
  • CSRF: browser invia cookie automaticamente. Mitigazioni: cookie SameSite=Lax o Strict per flussi “typical navigation”, CSRF token o pattern double submit per SameSite=None cross-site, oppure evitare cookie su API pubbliche cross-origin usando solo bearer da memoria (con tradeoff XSS più duro).

Pattern consigliato (alto livello)

  1. Access token: breve TTL (5–15 min), tenuto in memoria JS (closure/store volatile) quando possibile. Se serve persistenza tra tab, valuta cookie di sessione wrapper lato API piuttosto che localStorage.
  2. Refresh token: cookie HttpOnly + Secure + path stretto /auth/refresh, renew con POST dedicata. Rotazione: ogni refresh emette nuovo refresh e invalida il precedente server-side (store con jti o session table).
  3. Revoca: blacklist / versione sessione sul server così un refresh rubato smette dopo un round.
Set-Cookie: refresh=...; HttpOnly; Secure; Path=/auth/refresh; SameSite=Lax; Max-Age=...

Cosa evitare

  • Refresh in localStorage salvo threat model esplicito (es. solo mobile embedded WebView con altre guardie).
  • Lunghi access token “perché così facciamo meno refresh”—composti il rischio device rubato.
  • Multiple refresh concorrenti senza dedup: quattro tab possono generare quattro sessioni—usa mutex client (refreshPromise condiviso) o endpoint idempotente.

SPA + API su altro dominio

Se frontend è app.example.com e API api.example.com:

  • Cookie cross-subdomain: Domain=.example.com + Secure + SameSite coerente con flusso OAuth/PKCE che usi.
  • Alternativa moderna: Backend-for-Frontend stesso-site che fa proxy—riduce complessità CORS/cookie.

Logout

Cancella cookie refresh con Max-Age=0, invalida server-side jti, e azzera store client in memoria. Se ti basta logout locale senza chiamare API, il refresh rimasto valido è ancora pericoloso.

RFC 9700 (BCP 240): refresh per client pubblici

Il RFC 9700 (Best Current Practice for OAuth 2.0 Security, BCP 240) è la “linea guida OAuth2” aggiornata (2025). Per questo articolo, la sezione rilevante è la replay detection sui refresh token:

  • Per client pubblici (tipico: SPA senza secret) l’authorization server deve usare refresh token rotation oppure token sender-constrained (DPoP/mTLS). Con la rotazione, ogni refresh risponde con nuovo refresh e invalida il precedente; se qualcuno riusa un refresh già consumato, il server rileva competizione legittima vs attaccante e può revocare la catena.
  • Testo utile da citare in review: “If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token…” (RFC 9700 §4.14).

Non è “paper teorico”: è lo standard di riferimento quando integri IdP seri (Auth0, Keycloak, Azure AD, …) con grant browser-based.

Riferimenti

OWASP Session Management Cheat Sheet, RFC 9700, e RFC 6749 per terminologia refresh/OAuth—adatta al tuo grant; molte SPA usano session custom invece di OAuth puro.

Sintesi

Refresh HttpOnly, access corto in memoria, rotazione + revoca server, dedup refresh. localStorage per secret lunghi è la scorciatoia più costosa che vediamo in produzione.

Vuoi applicare idee come queste al tuo prodotto?

Raccontaci contesto, vincoli e obiettivi: ti diciamo se ha senso lavorare insieme e come impostare il primo passo.