Every team I have worked on has had the localStorage argument. Someone says tokens belong in cookies. Someone else says cookies mean CSRF. Both cite a blog post. The token stays where it was.
The argument is unwinnable as stated, because both sides are describing the container — and the container is not what decides the outcome.
Ask why one mechanism is safer and you get answers about persistence, capacity, expiry, whether it survives a tab close. Real differences. None of them has anything to do with an attacker.
One question: can JavaScript running on your origin read it?
If yes, anything achieving script execution on your page reads it too. Not encryption — you have nowhere to put the key. Not obfuscation — the attacker has your bundle. Not "we only write it after login" — the attacker runs after login too.
| Mechanism | Readable by page JS | Under XSS |
|---|---|---|
localStorage | yes | gone |
sessionStorage | yes | gone |
| IndexedDB | yes | gone |
| Cache API | yes | gone |
Cookie without HttpOnly | yes | gone |
Cookie with HttpOnly | no | survives |
Five of those six rows are the same row. The debate spends its energy on a distinction that exists only in the last line — and it is not the cookie that creates it, it is the flag.
HttpOnly is unusual: a capability the browser withholds from your own code. You cannot
opt back in at runtime. No API reads it, no devtools trick your bundle can perform. The
value goes out on requests to its domain and is otherwise unreachable.
Almost nothing else in the browser works this way. Everything in the first five rows is a filing cabinet in a public lobby — useful, convenient, not a vault, and never described as one by anyone who built it.
HttpOnly protects the token from being read. It does not stop it being used.
Script on your origin can still fetch with credentials: "include", and the browser
attaches the cookie. The attacker does not hold your session; they operate it, from your
page, while it is open. Smaller blast radius — no exfiltration, no replay next week — but
not nothing.
The honest summary: HttpOnly converts permanent theft into temporary misuse. Real
and worthwhile. Not immunity.
Stop asking "is this sensitive" — a word that invites negotiation. Ask: if an attacker had this value, what could they do, and for how long?
- Session token — act as the user until expiry.
- Refresh token — mint new sessions, far longer. Higher risk, and the one most often
parked in
localStoragebecause it needs to persist. - Cached API response with personal data — disclosure, not takeover. The Cache API is the mechanism people forget is storage at all.
- Feature flag, theme, draft — nothing. Put it anywhere.
That ordering puts refresh tokens above session tokens, which is the reverse of how they usually get handled.
Never on the same axis either. CSRF is the browser attaching a cookie to a request the
user did not intend — a property of ambient authority, not storage — and SameSite
addresses it directly.
That is why the debate feels unresolvable: one camp describes a read primitive, the other
a send primitive. HttpOnly answers the first, SameSite the second. Orthogonal flags on
the same cookie. Choosing between localStorage and cookies answers neither.
Reading storage you should not have access to is
CWE-522, insufficiently protected
credentials, under
A07:2021.
HttpOnly and Secure are specified in
RFC 6265 §4.1.2.5–6;
SameSite is not in RFC 6265 at all — it arrived later in
RFC 6265bis, still
a draft despite universal implementation. Both are documented on
MDN's Set-Cookie page.
For severity language, that is what CVSS is for; the OWASP categories give you shared vocabulary without adjectives.
All five equivalent rows are reached the same way: script execution on your origin. So storage is downstream of the XSS question, and a team that has not settled how untrusted data reaches a DOM sink is arguing about where to put the token while leaving the door open. A forthcoming companion piece works through why those sources outnumber the sinks.
What is in your localStorage right now? Not what you think — open devtools and look. I
have found a refresh token, a full user object with an email, and a cached permissions
array in codebases I would have defended.
I write about measurement and static analysis at dev.to/ofri-peretz.
