Storage Security, Encryption & Privacy
Browser storage is not a vault. localStorage, sessionStorage, IndexedDB, and the Cache API are all readable by any JavaScript running on the origin, persist in plaintext on disk, and are increasingly partitioned, evicted, or wiped by privacy engines you do not control. For offline-first Progressive Web Apps that hold auth tokens, personally identifiable information, and cached API payloads on the device for days, treating storage as a trusted store is the single most common security mistake frontend teams make. This guide establishes a defensible model: what each storage primitive actually guarantees, how to encrypt data at rest with the native Web Crypto API, how to keep credentials out of script-readable surfaces, and how to stay resilient when Safari’s privacy controls clear your data. It is the security companion to Browser Storage Fundamentals & Quotas and the durability patterns in Offline Sync Strategies & Background Workflows.
The storage security landscape
Each primitive carries a different threat profile. The table below summarizes what an attacker with script execution, an attacker with disk access, and a privacy engine can each do to your data. Use it to decide where a given class of data may live.
| Storage API | Script-readable | Encrypted at rest by default | Survives privacy wipe | Safe for secrets | Primary risk |
|---|---|---|---|---|---|
localStorage |
Yes (same origin) | No | Often cleared first | No | XSS exfiltration of tokens |
sessionStorage |
Yes (per tab) | No | Cleared on tab close | No | XSS within the session |
| IndexedDB | Yes | No (OS disk encryption only) | Evicted under pressure | Only if app-encrypted | Bulk PII exfiltration |
| Cache API | Yes | No | Evicted under pressure | No | Cached authorized responses leak |
Cookies (HttpOnly) |
No | No | Mostly survives | Yes, for session refs | CSRF if not SameSite |
The decisive column is Script-readable. Every Web Storage and IndexedDB value is visible to any script on the origin, which means a single cross-site scripting (XSS) flaw exposes everything stored there. The only browser store JavaScript cannot read is an HttpOnly cookie. This is why the recommendation throughout this guide is consistent: keep bearer credentials out of script-readable storage, and when you must persist sensitive payloads locally, encrypt them before they touch the disk. For the quota and eviction mechanics referenced above, see Storage Quotas & Eviction Policies.
Core concept 1 — Encrypting data at rest with Web Crypto
The native SubtleCrypto interface (crypto.subtle) ships in every modern browser and provides authenticated encryption without a third-party library. For data at rest, AES-GCM is the right primitive: it provides confidentiality and integrity (tampering is detected on decrypt) in one operation. The pattern is to derive a key, generate a fresh 96-bit initialization vector (IV) per write, and store the IV alongside the ciphertext. A deeper, copy-pasteable walkthrough lives in Encrypting Browser Storage with Web Crypto.
// Encrypt a JSON-serializable value with AES-GCM and return a self-describing record.
interface SealedRecord {
iv: number[]; // 12-byte initialization vector, unique per write
ct: number[]; // ciphertext bytes
}
async function seal(key: CryptoKey, value: unknown): Promise<SealedRecord> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(JSON.stringify(value));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext,
);
return { iv: Array.from(iv), ct: Array.from(new Uint8Array(ciphertext)) };
}
async function open<T>(key: CryptoKey, record: SealedRecord): Promise<T> {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(record.iv) },
key,
new Uint8Array(record.ct),
);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
}
The hard problem is never the cipher — it is key management. A key hardcoded in your bundle or cached in localStorage offers no protection against the XSS attacker who already runs in your origin; they can simply call open() themselves. Genuine at-rest protection comes from deriving the key from a secret the page does not persist, such as a user passphrase fed through PBKDF2, or a key delivered per-session from the server and held only in memory. Mark derived keys as non-extractable so crypto.subtle.exportKey cannot dump raw bytes, and store only the wrapped form when persistence is unavoidable.
Core concept 2 — Keeping credentials out of script-readable storage
The most consequential decision is where the session credential lives. A JSON Web Token (JWT) in localStorage is convenient and catastrophic: any XSS payload reads localStorage.token and exfiltrates it to an attacker server, and the token remains valid until expiry regardless of logout. The architectural fix is to keep the long-lived refresh credential in an HttpOnly, Secure, SameSite cookie that JavaScript cannot touch, and hold only a short-lived access token in memory (a module-scoped variable), never in persistent storage.
// Access token lives only in memory for the page's lifetime. A page reload
// triggers a silent refresh against the HttpOnly cookie, so nothing sensitive
// is ever written to localStorage / IndexedDB / sessionStorage.
let accessToken: string | null = null;
async function getAccessToken(): Promise<string> {
if (accessToken) return accessToken;
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // sends the HttpOnly refresh cookie
});
if (!res.ok) throw new Error('Session expired');
accessToken = (await res.json()).accessToken;
return accessToken;
}
This trades a small amount of startup latency (one refresh round-trip after reload) for the elimination of an entire class of token-theft attacks. The rationale and the XSS attack walkthrough are covered in Securing Auth Tokens in Browser Storage. When you cache user data offline for the same app, encrypt it with the Core concept 1 pattern and store the ciphertext in IndexedDB rather than dropping raw PII into localStorage vs sessionStorage string slots.
Core concept 3 — Partitioning, privacy engines, and data lifetime
Modern browsers no longer let an origin keep storage indefinitely. Safari’s Intelligent Tracking Prevention (ITP) caps script-writable storage to a 7-day lifetime for sites the user has not engaged with as an installed app, and clears IndexedDB, the Cache API, and localStorage after that window of inactivity. All major browsers now also partition third-party storage by the top-level site, so an iframe from your CDN gets a different storage bucket per embedding site. Security-sensitive flows must therefore treat local data as ephemeral: never make a security decision (such as “the user is still authenticated”) solely on the presence of a local value, because the value may have been wiped.
// Request persistent storage to resist eviction, and always treat a missing
// session marker as logged-out rather than trusting its mere presence.
async function hardenStorageLifetime(): Promise<void> {
if (navigator.storage?.persist) {
const persisted = await navigator.storage.persist();
console.info(`Persistent storage granted: ${persisted}`);
}
}
function isSessionUsable(marker: { expiresAt: number } | null): boolean {
// Presence is necessary but never sufficient — re-validate against the server.
return marker !== null && marker.expiresAt > Date.now();
}
The full set of partitioning behaviors, the Storage Access API, and how to debug an ITP wipe are detailed in Storage Partitioning & Privacy Controls.
Production patterns & gotchas
- iOS Safari 16/17 eviction surprises. On iOS Safari 16 and 17, a PWA added to the Home Screen runs in a storage context that can differ from the in-browser tab, and the 7-day cap applies aggressively to the browser context. Mobile teams routinely see “logged out overnight” reports that are really ITP clearing the token store. Persist the refresh flow server-side so a wiped client recovers with one network round-trip instead of forcing a full re-login.
- Cross-tab key leakage. If you derive an encryption key in one tab and broadcast it via
BroadcastChannelor astorageevent, you have just written the key to a script-readable channel. Derive per-tab, or coordinate access through the Web Locks API for Cross-Tab Coordination without ever transmitting the raw key. - Cache API leaks authorized responses. A stale-while-revalidate cache can persist a response that included another user’s data after account switching. Scope cache names per user and purge on logout; coordinate this with Service Worker Caching Strategies.
- Encryption is not access control. Encrypting IndexedDB records stops disk-forensics and other-app reads, but does nothing against an XSS attacker who can call your own
open()helper. Encryption complements, never replaces, a strict Content Security Policy.
Quick reference
| Data class | Where it belongs | Protection |
|---|---|---|
| Refresh credential | HttpOnly Secure cookie |
Out of JS reach entirely |
| Access token | In-memory variable | Never persisted; silent refresh on reload |
| Cached PII / payloads | IndexedDB, AES-GCM encrypted | Web Crypto seal/open + non-extractable key |
| Static assets | Cache API | No secrets; purge per-user on logout |
| Feature flags / theme | localStorage |
None needed (non-sensitive only) |
Frequently Asked Questions
Is localStorage ever safe for storing a JWT?
No. Any script on the origin can read localStorage, so a single XSS flaw exfiltrates the token, and it stays valid until it expires. Keep the long-lived credential in an HttpOnly cookie and hold only a short-lived access token in memory. See Securing Auth Tokens in Browser Storage.
Does encrypting IndexedDB stop an XSS attacker?
Not on its own. If the attacker runs in your origin they can call the same decryption helper your app uses. Encryption protects against disk forensics, shared-device snooping, and other-origin reads — it must be paired with a strict Content Security Policy to address script injection.
Why did my PWA log the user out after a week on iPhone?
Safari’s Intelligent Tracking Prevention clears script-writable storage after 7 days of inactivity for non-installed origins, wiping your token store. Design recovery around a server-side refresh so the client re-hydrates with one request rather than a full login. Details in Storage Partitioning & Privacy Controls.
Should I use a crypto library or the native Web Crypto API?
Prefer native crypto.subtle. It is implemented in every modern browser, runs in optimized native code, and avoids shipping cipher implementations in your bundle. Reach for a library only for algorithms the platform lacks, and never paste secret-bearing dependencies into the page.
Related
- Encrypting Browser Storage with Web Crypto — AES-GCM seal/open patterns and key derivation in depth.
- Securing Auth Tokens in Browser Storage — the in-memory + HttpOnly architecture and the XSS threat model.
- Storage Partitioning & Privacy Controls — ITP, third-party partitioning, and the Storage Access API.
- Browser Storage Fundamentals & Quotas — the foundational primitives this security model builds on.
- Offline Sync Strategies & Background Workflows — durability patterns for the data you are protecting.