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) | localStorage | sessionStorage | |
|---|---|---|---|---|
| Readable by page JavaScript | No | Yes (document.cookie) | Yes | Yes |
| Sent to the server | Automatically, every matching request | Automatically | Only if your code adds it | Only if your code adds it |
| Lifetime | Session, or Max-Age/Expires (≤ 400 days in Chrome) | Same | Until cleared | Until the tab closes |
| Size | ~4 KB each | ~4 KB each | ~5 MB per origin | ~5 MB per origin |
| Scope | Host or domain + path | Same | Exact origin | Exact origin, one tab |
| Stolen by an XSS bug? | Not readable (but the attacker can still make requests while you're on the page) | Yes | Yes | Yes |
| CSRF risk | Yes, mostly closed by SameSite=Lax/Strict | Same | No (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)
- Store an opaque random ID in the cookie and keep the real session data on the server, or use a short-lived signed token.
- Don't put personal data or secrets in localStorage: browser extensions and any script on the origin can read it, and it never expires.
- For SPAs that call an API on another domain, cookies need CORS with credentials; see CORS with credentials. Hosting the API on a subdomain of the app keeps cookies same-site.
- If a third-party API requires a bearer token, keep it in memory, refresh it with an HttpOnly refresh cookie, and give it a short lifetime.
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 .