Every few months someone rediscovers localStorage, decides cookies are old, and stores session tokens in it. A few months later they get the XSS report. Let’s settle the debate.
The real criterion: who can read it
HttpOnlycookie: readable only by the server. JavaScript on the page cannot touch it.- localStorage: readable by any JavaScript that runs on your origin. That includes third-party scripts (analytics, ads, A/B testing), compromised dependencies, and any XSS payload.
For session credentials, that’s the whole argument. An XSS vulnerability in any code running on your origin hands the attacker your localStorage in full. The same XSS cannot read an HttpOnly cookie.
People will say “but if you have XSS, you have bigger problems.” True, but: (a) XSS still happens regularly even in CSP-hardened apps, and (b) the point of defense-in-depth is that you don’t lose everything when one layer fails.
The common counterarguments
“Cookies are sent on every request, it’s wasteful.” Set the Path attribute. Session cookies scoped to / are ~100 bytes. On HTTPS with HTTP/2 header compression, this is in the noise.
“CSRF is hard with cookies.” SameSite=Lax is the default now; it blocks the attack. If you have weird cross-site requirements, use SameSite=None and add a CSRF token. Both are well-trodden paths.
“I need the token in JavaScript to attach as Authorization: Bearer.” You don’t, actually. credentials: "include" on fetch sends the cookie automatically. The only time you need a JS-accessible token is when you’re calling an API on a different origin that doesn’t accept cookies — in which case, use a token fetched fresh per request, not stored in localStorage.
“My SPA uses JWTs and they have to go somewhere.” Short-lived (5-minute) JWT access tokens can live in memory — a JavaScript variable that vanishes on page refresh. Refresh via a cookie-bound refresh token. Best of both worlds.
The rule
Session tokens → HttpOnly cookies. Period.
localStorage is fine for:
- UI preferences (theme, layout)
- Draft state (unsaved forms)
- Feature flag overrides in dev
- Anything that isn’t a credential
If you inherit a codebase using localStorage for sessions, migrating to cookies is a week of work that closes a class of vulnerability. Worth doing.