Back to Security
Security
medium
mid

What can you safely store in localStorage and sessionStorage?

localStorage and sessionStorage are accessible from JS — XSS readable, no HttpOnly equivalent. Never store: auth tokens (use HttpOnly cookies), passwords, credit cards, encryption keys, PII without consent. OK to store: UI prefs (theme, language), drafts (low-sensitivity), feature flag overrides, public metadata. For sensitive client-side persistence use IndexedDB with same caveats + consider encryption with key derived per-user. Clear on logout. Scope by user when shared device is possible.

8 min read·~5 min to think through

localStorage and sessionStorage are convenient but have specific security properties that determine what's safe to put there.

What they are

  • localStorage: 5–10MB, sync API, persists across sessions and tabs, scoped per origin.
  • sessionStorage: same but cleared on tab close.

Both are JS-readable — any script on the page (including XSS) can read everything. There's no HttpOnly equivalent.

Threat model

If an XSS happens on your site:

  • All localStorage / sessionStorage is exfiltrated immediately.
  • HttpOnly cookies survive (script can't read them).
  • IndexedDB is also exfiltrable.
  • Service worker caches can be read.

So the question is: how bad is it if all this content is leaked?

Don't store

  • Auth tokens / session ids → use HttpOnly + Secure + SameSite cookies. JS shouldn't touch them.
  • Passwords or credentials.
  • Credit card numbers, CVVs, full SSNs.
  • Encryption keys (defeats the purpose if leakable).
  • PII without explicit user consent / regulatory clearance.
  • API keys for paid services — bills add up fast.

OK to store

  • UI preferences: theme, language, sidebar state.
  • Form drafts for low-sensitivity forms (a blog comment in progress).
  • Feature flag overrides the user toggled.
  • Public metadata: last-viewed timestamps, recently-visited items.
  • Non-sensitive cache: TTL'd API responses (public data only).

Subtle leaks

  • Browser extensions can read your localStorage.
  • Subdomains share localStorage if they share an origin — but a.example.com and b.example.com are different origins by default.
  • Embedded scripts (analytics, A/B testing) have full access.
  • Stack Overflow examples sometimes recommend storing JWTs in localStorage; don't.

Where to put auth tokens

Best: HttpOnly + Secure + SameSite=Lax cookie. Server reads from header automatically. JS can't read or steal. Survives XSS.

ts
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Lax; Max-Age=3600

OK with caveats: in-memory variable (lost on page reload but not exfiltrable from disk).

Bad: localStorage. XSS == game over.

The "but I need to read the token in JS for fetch headers" argument is wrong — cookies are sent automatically with same-origin requests; for cross-origin APIs, use a Bearer scheme with short-lived tokens fetched via a proxied endpoint.

IndexedDB

For larger client-side data (offline apps, big lookups):

  • Same JS-readable threat model.
  • Async API; can be wrapped (Dexie, idb).
  • Can be cleared by users without warning.
  • Safari sometimes evicts on storage pressure or after 7 days of inactivity (ITP).

For sensitive data in IndexedDB, encrypt with a key derived from user input or session-bound material. But: the decryption key has to live somewhere; if it's in JS memory, XSS still beats you. Encryption helps against device theft / disk forensics, not against XSS.

Clearing on logout

js
function logout() {
  localStorage.clear();
  sessionStorage.clear();
  // clear cookies via fetch('/logout') that responds with Set-Cookie expiration
  // clear IndexedDB
  indexedDB.databases().then(dbs => dbs.forEach(db => indexedDB.deleteDatabase(db.name)));
}

Don't leave the previous user's data accessible to the next user on a shared device.

Scoping by user

If multiple users might share a device, namespace storage keys:

js
const key = `u:${userId}:preferences`;
localStorage.setItem(key, JSON.stringify(prefs));

Size limits

  • localStorage / sessionStorage: 5–10MB per origin.
  • IndexedDB: large (gigabytes), but Safari limits more aggressively.
  • Handle QuotaExceededError.

Cross-tab sync

storage event fires when another tab writes to localStorage:

js
window.addEventListener('storage', e => {
  if (e.key === 'theme') applyTheme(e.newValue);
});

Useful for multi-tab consistency (logout in one tab logs out others).

Quick decision flow

  1. Is it auth/credentials/secrets? → HttpOnly cookie.
  2. Is it sensitive PII? → Server-side; don't store in browser without compliance review.
  3. Is it a few KB of UI preferences or low-stakes cache? → localStorage / sessionStorage.
  4. Is it big offline data? → IndexedDB, encrypted if sensitive.
  5. Is it just for this tab? → sessionStorage.

Pitfalls

  • Storing JWT in localStorage "because the docs said so."
  • Encrypting in JS with a key also in JS — security theater.
  • Not clearing on logout.
  • Storing PII without consent / regulatory review.
  • Trusting third-party scripts on the same origin not to exfiltrate.
  • Forgetting that subdomains may share storage.
  • No QuotaExceededError handling.

Mental model

localStorage is a JS-readable bucket. Anything sensitive there is one XSS away from leaking. Use cookies (HttpOnly) for secrets, localStorage for UI/preferences, IndexedDB for bulk non-secret data. Clear on logout; scope by user when shared device is plausible.

Follow-up questions

  • Why is localStorage worse than HttpOnly cookies for auth tokens?
  • How does IndexedDB compare for sensitive data?
  • What's Safari's ITP and how does it affect storage?
  • When does cross-tab storage event become useful?

Common mistakes

  • JWT in localStorage — XSS exfiltrates it.
  • Encrypting in JS with key in JS — defeats encryption.
  • Storing PII without consent.
  • Not clearing on logout.
  • Trusting third-party scripts on same origin.
  • Assuming storage is durable — users / browser can clear it.

Performance considerations

  • localStorage is synchronous and small — fine for KB-scale data. For larger or async-friendly persistence, use IndexedDB. Storage I/O on the main thread can be slow on mobile; offload to worker if you're storing megabytes.

Edge cases

  • Safari ITP can clear storage after 7 days of inactivity.
  • Subdomains: a.example.com and b.example.com are different origins by default.
  • Private browsing modes may have ephemeral or quota-limited storage.
  • Browser extensions can read localStorage.
  • QuotaExceededError on big writes — handle gracefully.

Real-world examples

  • Most apps store theme + UI prefs in localStorage.
  • Notion / Linear use IndexedDB for offline sync.
  • Auth0, Clerk, NextAuth — recommend HttpOnly cookies for tokens, not localStorage.

Senior engineer discussion

Seniors reach for HttpOnly cookies for anything sensitive, localStorage for UI prefs, and IndexedDB for bulk non-secret data. They scope by user, clear on logout, and don't pretend client-side encryption protects against XSS.

Related questions