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.

Browser storage threat surface and mitigations A diagram showing how XSS, disk access, and privacy engines threaten the four browser storage primitives, and which mitigations apply to each. Threats XSS / injected JS Disk / device access Privacy engine wipe Storage surface localStorage sync · plaintext IndexedDB async · structured Cache API Response objects Mitigations CSP + HttpOnly Web Crypto AES-GCM persist() + re-auth No storage is a security boundary Encrypt sensitive data, scope secrets server-side, assume the disk is readable

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.

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.

Triage flowchart for placing a data class in browser storage A decision tree: if the data is a bearer secret it belongs server-side in an HttpOnly cookie; else if it is private but needed offline, AES-GCM encrypt it into IndexedDB; else if it is non-sensitive, plain localStorage or the Cache API is fine. Is it a bearer secret? session, refresh token, JWT Server-held / HttpOnly cookie out of JavaScript's reach entirely Yes Private, but needed offline? PII, cached API payloads AES-GCM encrypt → IndexedDB non-extractable key held in memory Yes No Non-sensitive? feature flags, theme, UI state Plain localStorage / Cache API no protection required Yes No

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.

Silent-refresh token flow between the browser and the auth server After a page reload the in-memory access token is gone. The browser POSTs to /auth/refresh; the HttpOnly refresh cookie is attached automatically. The server validates it, rotates the refresh token, and returns a short-lived access token that JavaScript holds only in memory and never writes to disk. When it expires the cycle repeats. Browser (page JS) Auth server Reload: in-memory token lost POST /auth/refresh HttpOnly refresh cookie attached automatically 1 Validate cookie, rotate refresh token 2 200 — short-lived access token 3 Held in memory only — never on disk 4 On expiry the cycle repeats — nothing sensitive is ever persisted

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

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