Library
7 min read

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.
Your answer

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#

CookielocalStoragesessionStorageIn memory (JS variable)
Sent to server automaticallyYes, every matching requestNoNoNo
Readable by JavaScriptNot if HttpOnlyYesYesYes, by code in scope
Survives reloadYes, until expiryYes, foreverYes, per tabNo
Shared across tabsYesYes, per originNoNo
SizeAbout 4 KB per cookieAbout 5 MB per originAbout 5 MBMemory

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#

1/3 One line. The script runs on your origin, so it has every right to read it.

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.

http
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, has Path=/ and has no Domain, 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#

ChoiceWhat you gainWhat you payPick 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 cookieXSS 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 localStorageSimplest 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.
Third party scripts are the usual XSS

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#

Interviewer
Where should a React app store its JWT?
They expect a trade off, not a one word answer.
You
Ideally not in the browser at all: a small backend for frontend holds the tokens and gives the browser an HttpOnly, Secure, SameSite=Lax session cookie. If there is no backend, I keep the access token in memory and the refresh token in an HttpOnly cookie scoped to the refresh endpoint. I avoid localStorage for tokens because any XSS, including a compromised third party script, can read it and use it from anywhere until it expires. Cookies bring CSRF into play, which SameSite plus an Origin check handles.
Ranks the options and names the attack each one is exposed to.
Interviewer
But XSS can still make requests with the cookie. So what did HttpOnly buy you?
You
It contains the damage to the open tab and the moment. The attacker cannot take the credential away and replay it from their own infrastructure for days. Fixing XSS itself is a separate job: output encoding, a strict Content Security Policy, and auditing third party scripts.
Good place for it?
A refresh token in localStorage
The user's dark mode preference in localStorage
A session id in a cookie without HttpOnly
A half written blog draft in localStorage

Checkpoint#

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?

Worth memorising
  • 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.
Say this in 60 seconds

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.