localStorage vs cookies: where should a login token go?

Cookies travel to the server automatically on every request; localStorage stays in the browser until your JavaScript reads it. That one difference decides most of the security trade-off.

Side by side

Cookie (HttpOnly)Cookie (readable)localStoragesessionStorage
Readable by page JavaScriptNoYes (document.cookie)YesYes
Sent to the serverAutomatically, every matching requestAutomaticallyOnly if your code adds itOnly if your code adds it
LifetimeSession, or Max-Age/Expires (≤ 400 days in Chrome)SameUntil clearedUntil the tab closes
Size~4 KB each~4 KB each~5 MB per origin~5 MB per origin
ScopeHost or domain + pathSameExact originExact origin, one tab
Stolen by an XSS bug?Not readable (but the attacker can still make requests while you're on the page)YesYesYes
CSRF riskYes, mostly closed by SameSite=Lax/StrictSameNo (not sent automatically)No

The XSS argument

If an attacker gets one line of script onto your page (a malicious npm package, an unescaped comment), it can read everything in localStorage and readable cookies and post it to their server. They can then use the token from anywhere for as long as it's valid. An HttpOnly cookie can't be read, so the damage is limited to what the script can do while the victim has the page open. That's still bad, but it's contained.

The CSRF argument

Cookies ride along automatically, so another site could once trick your browser into posting a form to your bank. SameSite=Lax (Chrome's default) stops cookies on cross-site POSTs, images and iframes, and Strict stops them even on links. For state-changing actions, add a CSRF token or check the Origin header too, especially if you need SameSite=None.

A sensible setup

# Login response
Set-Cookie: __Host-session=opaque-random-id; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600

# Non-secret UI state
localStorage.setItem('theme', 'dark')
sessionStorage.setItem('draft', text)

What about JWTs?

A JWT is just a format; it can live in a cookie or in localStorage. Its payload is Base64url, not encrypted, so anyone holding it can read it (see decoding JWTs safely and Base64 is not encryption). Size matters too: a big JWT can push a cookie over the 4 KB limit, at which point the browser silently drops it.

Related

Why is my cookie not being set? · CSRF token missing or incorrect · 431 Request Header Fields Too Large · .env files and secrets · Wirebook's cookie jar lesson for a gentle introduction.

Questions

Is it safe to store a JWT in localStorage?

It works, but any script running on your page can read and steal it. An HttpOnly cookie is safer for login sessions because JavaScript cannot read it.

Are cookies vulnerable to CSRF?

They can be, because browsers send them automatically. SameSite=Lax or Strict blocks most cross-site requests, and a CSRF token or Origin check covers the rest.

How much can localStorage hold compared with cookies?

Around 5 MB per origin for localStorage, versus about 4 KB per cookie (and a limit on how many cookies each domain can have).

Does localStorage expire?

No. It stays until your code, the user or the browser clears it. sessionStorage is cleared when the tab closes.

Can the server read localStorage?

No. Only JavaScript in the page can, and it must send the value itself, for example in an Authorization header.

Published .

Try it

Related guides

Newest guides