DEV Community

Cover image for Your Token Is Not Safer in a Cookie. It Is Safer From JavaScript.
Ofri Peretz
Ofri Peretz

Posted on • Originally published at ofriperetz.dev

Your Token Is Not Safer in a Cookie. It Is Safer From JavaScript.

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.


The axis everyone argues about

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.

The axis that decides it

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 the boundary

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.

The corollary people skip

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.

So what counts as sensitive

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 localStorage because 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.

And the CSRF half?

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.

Where this lands in the standards

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 arrived later, in
RFC 6265bis, still
a draft despite universal implementation. Both are on
MDN's Set-Cookie page.
For severity language, use
CVSS and
the OWASP categories.

Why the argument keeps happening

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. DOM sinks and sources, the
companion piece in this series, 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.

Top comments (0)