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.
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.
Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Lax; Max-Age=3600OK 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
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:
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:
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
- Is it auth/credentials/secrets? → HttpOnly cookie.
- Is it sensitive PII? → Server-side; don't store in browser without compliance review.
- Is it a few KB of UI preferences or low-stakes cache? → localStorage / sessionStorage.
- Is it big offline data? → IndexedDB, encrypted if sensitive.
- 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.