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 — review that section first for the foundational primitives this model builds on — and to 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; for how values are turned into stored bytes in the first place, see Data Serialization & Deserialization.
Building a threat model for local storage
Before choosing a mitigation you need to name the adversary, because the three storage threats defend against completely different attackers and no single control covers all of them.
- The script attacker (XSS). An injected
<script>— from a compromised dependency, a reflected input, or a malicious third-party tag — runs with your origin’s full privileges. It can read everylocalStoragekey, open every IndexedDB database, iterate the Cache API, and call any decryption helper you shipped. Client-side encryption does not stop this attacker; only keeping the secret out of the browser (anHttpOnlycookie, a server-held session) and a strict Content Security Policy do. The canonical example is dissected in Why JWT in localStorage Is XSS-Vulnerable. - The device attacker (disk / shared machine). Someone with the raw disk, a forensic image, a backup, or physical access to an unlocked shared computer can read the plaintext SQLite and LevelDB files where browsers keep storage. Here encryption at rest is exactly the right control: an AES-GCM ciphertext on disk is useless without the key, which lives only in memory or is derived from a user secret.
- The privacy engine (browser policy). Safari’s Intelligent Tracking Prevention, Firefox’s Total Cookie Protection, and Chrome’s Storage Buckets can wipe, partition, or cap your data on a timer with no user action. This is not a confidentiality threat — it is an availability and correctness threat. It breaks apps that treat the presence of a local value as proof of a security fact.
Mapping each data class you store to the attacker that actually threatens it prevents the two classic mistakes: encrypting a token that an XSS attacker will simply decrypt for you, and leaving genuinely private cached payloads in plaintext on a shared device. The three core concepts that follow address these threats in turn.
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, and the string-slot variant in Encrypting localStorage Values with AES-GCM.
// 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. The most robust option is a user passphrase stretched through PBKDF2 with a high iteration count and a per-user random salt, so the derived key exists only while the user is actively unlocked.
// Derive a non-extractable AES-GCM key from a user passphrase. The salt is stored
// (it is not secret); the passphrase is never persisted. Raise iterations as
// hardware improves — 600k is a reasonable 2026 floor for PBKDF2-SHA-256.
async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const material = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600_000, hash: 'SHA-256' },
material,
{ name: 'AES-GCM', length: 256 },
false, // extractable = false: exportKey() cannot dump raw bytes
['encrypt', 'decrypt'],
);
}
Three invariants make this safe. First, IV uniqueness: AES-GCM catastrophically leaks plaintext relationships if an IV is ever reused under the same key, so always generate a fresh random 96-bit IV per seal() — never a counter you might reset, never a constant. Second, non-extractable keys: passing extractable: false means crypto.subtle.exportKey throws, so even injected script cannot serialize the raw key out of the page; when you must persist a key for reuse across sessions, wrap it under a key-wrapping key with wrapKey and store only the wrapped blob. Third, integrity is free: because GCM authenticates, a failed decrypt (a DOMException) means the ciphertext was truncated, corrupted by an eviction race, or tampered with — treat every decrypt failure as a hard error and re-fetch from the server rather than showing stale or partial data. When you store the resulting ciphertext, prefer IndexedDB over string storage so large binary payloads survive intact; see Storing Blobs & Files in IndexedDB for the binary-write patterns.
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 token flow is a sequence worth internalizing: the browser sends the HttpOnly refresh cookie automatically, the server mints a fresh short-lived access token, and JavaScript receives only that access token — which it holds in a closure and never writes to disk.
Two refinements harden this further. Content Security Policy is the partner control: the in-memory pattern removes the token from storage, but a strict Content-Security-Policy (no inline script, a tight script-src allowlist, object-src 'none') is what stops the XSS that would otherwise read the token straight out of memory before it can be exfiltrated. Refresh rotation: issue a new refresh token on every use and invalidate the old one server-side, so a stolen refresh cookie is detectable (a replay uses an already-rotated token) and the blast radius of any single leak is one request. The rationale and the full 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 navigator.storage.persist() grant moves your origin’s data from best-effort to persistent, which exempts it from eviction under storage pressure — but note it does not exempt it from ITP’s inactivity timer in Safari, where the 7-day clock still runs. For genuinely cross-partition flows, such as an embedded widget that needs its first-party storage inside an iframe, the Storage Access API (document.requestStorageAccess()) is the sanctioned path and requires a user gesture. The full set of partitioning behaviors, the Storage Access API, and how to debug an ITP wipe are detailed in Storage Partitioning & Privacy Controls, with a hands-on reproduction in Debugging Storage Cleared by Safari ITP.
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. - Decrypt failures on partial eviction. Because AES-GCM is authenticated, a record half-evicted or corrupted mid-write throws on
decryptrather than returning garbage. Wrap everyopen()in a try/catch that falls back to a server re-fetch, and never surface a rawDOMExceptionto the user as a login error. sessionStorageis not encrypted memory. It is tempting to treatsessionStorageas “safe because it clears on tab close,” but it is fully script-readable for the tab’s entire lifetime, so an XSS payload that runs at any point in the session drains it. Its shorter lifetime reduces exposure window, not exposure.
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.
Where should the AES-GCM encryption key actually come from?
Never from your bundle or from localStorage — an XSS attacker reads both. Derive it from a user passphrase via PBKDF2 (600k+ iterations, per-user salt) so it exists only while unlocked, or fetch a per-session key from the server and hold it in a non-extractable CryptoKey in memory. If you must persist a key, wrap it under a key-wrapping key and store only the wrapped blob.
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.
- IndexedDB Architecture & Advanced Patterns — structured storage for the encrypted payloads this guide produces.