Cookies vs localStorage
Published
Your single page app just received a token after login. It has to live somewhere so the next request can use it. The two obvious places are a cookie and localStorage, and the choice is really a choice about which attack you would rather defend against.
- Anything in localStorage can be read by any JavaScript running on your page. If an attacker gets a script onto your page (XSS), they read the token and send it to their server.
- A cookie marked HttpOnly cannot be read by JavaScript at all. But the browser attaches it to requests automatically, so you must think about CSRF.
A third party analytics script you load from a CDN gets compromised and starts running attacker code on your checkout page. Your access token is in localStorage. What can the attacker do? What could they do if it were in an HttpOnly cookie instead?
The storage options#
| Cookie | localStorage | sessionStorage | In memory (JS variable) | |
|---|---|---|---|---|
| Sent to server automatically | Yes, every matching request | No | No | No |
| Readable by JavaScript | Not if HttpOnly | Yes | Yes | Yes, by code in scope |
| Survives reload | Yes, until expiry | Yes, forever | Yes, per tab | No |
| Shared across tabs | Yes | Yes, per origin | No | No |
| Size | About 4 KB per cookie | About 5 MB per origin | About 5 MB | Memory |
Cookies were designed in 1994 for exactly this job, remembering a session, and have grown the attributes to do it safely. localStorage arrived around 2009 for app data: drafts, preferences, cached lists. It has no security controls at all, by design.
The two attacks, side by side#
That is the honest summary. HttpOnly does not make XSS harmless. It turns "attacker owns the account from their own machine for weeks" into "attacker can act inside the open tab while it is open". That is a large reduction in damage, and it is why the security guidance (OWASP, and the IETF's draft on OAuth for browser based apps) leans towards cookies, or towards keeping tokens out of the browser entirely.
Setting a session cookie properly#
Set-Cookie: __Host-session=9f3c1a7e5b2d4c8a; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=604800- HttpOnly: JavaScript cannot read it.
- Secure: only sent over HTTPS.
- SameSite=Lax: not attached to cross site POSTs, which handles most CSRF (see CORS vs CSRF).
__Host-prefix: the browser only accepts it if it is Secure, hasPath=/and has noDomain, so a compromised subdomain cannot overwrite it.- Max-Age: when it expires. Leave it out and it is a session cookie, deleted when the browser closes (although "continue where you left off" settings often keep it).
Do not set Domain=acme.in unless every subdomain should receive the cookie. Without it, the
cookie is sent only to the exact host that set it, which is safer.
The options for a single page app#
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Backend for frontend (BFF) | Tokens never reach the browser. It holds only an HttpOnly session cookie, and a small server attaches tokens when calling APIs. | You run and scale a small backend. | The recommended default for apps that handle money, health data or anything sensitive. |
| Access token in memory, refresh in an HttpOnly cookie | XSS cannot steal the long lived token. A reload silently gets a new access token. | XSS can still use the in memory token while the page is open. | SPAs without a dedicated backend, calling APIs on the same site. |
| Tokens in localStorage | Simplest to build, works across tabs, no CSRF to think about. | Any XSS, including a compromised third party script, steals the token outright. | Low value apps only, and never for refresh tokens. |
You do not need a bug in your own code for XSS. Every analytics tag, chat widget and A/B testing snippet runs with full access to your page. The 2018 British Airways breach skimmed around 380,000 payment cards through modified JavaScript on its booking pages, and Magecart style skimmers have hit thousands of shops the same way. A Content Security Policy and Subresource Integrity hashes on third party scripts are the defences.
In the interview#
Checkpoint#
1. An injected script runs on your page. Your token is in localStorage. What is the worst outcome?
2. What does HttpOnly stop?
3. Which cookie attribute makes the browser reject a cookie unless it is Secure, Path=/ and has no Domain?
- localStorage: readable by any script. XSS steals what is there.
- HttpOnly cookie: unreadable by scripts, sent automatically. Handle CSRF with SameSite.
- HttpOnly limits XSS damage, it does not prevent XSS.
- Session cookie:
__Host-, Secure, HttpOnly, SameSite=Lax. - Best for SPAs: a backend for frontend, so tokens never reach the browser.
Choosing between cookies and localStorage for tokens is choosing which attack to defend against. Anything in localStorage is readable by any script on the page, so an XSS, including a compromised third party script, can steal the token and use it from anywhere until it expires. An HttpOnly cookie cannot be read by scripts, which contains XSS to the open tab, but the browser sends it automatically, so CSRF has to be handled, mostly by SameSite=Lax plus an Origin check. I set session cookies with the __Host- prefix, Secure, HttpOnly and SameSite=Lax. For single page apps I prefer a backend for frontend that keeps tokens server side; failing that, the access token in memory and the refresh token in an HttpOnly cookie. localStorage is for preferences and drafts, not credentials.