Encrypting Browser Storage with Web Crypto
Browser storage is plaintext on disk. Anything you write to localStorage or IndexedDB sits in the profile directory readable by other applications on a shared machine, by anyone with physical access to an unlocked device, and by forensic tooling. The native Web Crypto API — exposed as crypto.subtle — lets you encrypt sensitive payloads before they touch the disk, using authenticated AES-GCM with keys the runtime can hold without ever exposing their raw bytes. This guide is the implementation companion to the security overview in Storage Security, Encryption & Privacy; it covers the full seal/open cycle, two distinct key-management models, non-extractable and wrapped keys, error handling, and the one thing encryption emphatically does not protect against. For the focused localStorage recipe, jump to Encrypting localStorage Values with AES-GCM.
What Web Crypto gives you
crypto.subtle is a built-in, native implementation of standard cryptographic primitives. It runs only in secure contexts — pages served over HTTPS or from localhost — and exposes encrypt, decrypt, deriveKey, deriveBits, generateKey, importKey, exportKey, wrapKey, and unwrapKey. Every method returns a Promise, so all storage code that encrypts must be asynchronous, even when the surrounding store (localStorage) is synchronous.
For data at rest, the algorithm to reach for is AES-GCM: a single operation that produces confidentiality and integrity. Galois/Counter Mode appends an authentication tag to the ciphertext, so any tampering — a flipped bit on disk, a truncated value — is detected at decrypt time and rejected. You never need a separate HMAC. The two non-negotiable rules of AES-GCM are: use a 256-bit key, and never reuse an (key, IV) pair. Generate a fresh 12-byte (96-bit) IV with crypto.getRandomValues on every single write and store it next to the ciphertext.
Parameters at a glance
| Parameter | Value | Why |
|---|---|---|
| Cipher | AES-GCM |
Authenticated encryption: confidentiality + integrity in one call |
| Key length | 256 bits |
Strongest AES variant; negligible cost over 128 in the browser |
| IV length | 12 bytes (96 bits) |
The GCM-recommended nonce size; fresh per write |
| IV reuse | Never | Reusing an IV with the same key breaks GCM catastrophically |
| KDF | PBKDF2 |
Stretches a low-entropy passphrase into a key |
| KDF hash | SHA-256 |
Standard PRF for PBKDF2 in browsers |
| KDF iterations | ~100000+ |
Slows brute force; tune to UX budget |
| Salt | 16 random bytes |
Unique per user; stored alongside, not secret |
extractable |
false |
Key bytes can never be read back out via exportKey |
The IV and the salt are not secrets — they are stored in the clear next to the ciphertext. Their job is uniqueness, not confidentiality. What must stay secret is the key, and the rest of this guide is about managing it well.
Two key-management models
The cipher is the easy part. Where the key comes from is the entire security story, and there are two defensible patterns.
Model A — derive from a user passphrase (PBKDF2). The user types a passphrase; you run it through PBKDF2 with a per-user salt and ~100,000 iterations to stretch it into a 256-bit AES key. Nothing secret is ever persisted: the salt is stored, but the key exists only in memory after the user authenticates, and is gone when the tab closes. This is the right model for a notes app, a password manager, or any “this device, this user” vault where re-prompting for a passphrase on each session is acceptable.
Model B — server-delivered per-session key held in memory. The server hands the client a freshly generated 256-bit key (or a wrapping key) over an authenticated TLS channel after login, and the client imports it as a non-extractable CryptoKey kept in a module-scoped variable. The key never touches storage; a page reload re-fetches it. This suits apps where the server is the source of truth and you want zero passphrase friction. It pairs naturally with the in-memory token model in Securing Auth Tokens in Browser Storage.
Both models share one principle: the key is never written to script-readable storage. A key cached in localStorage is no protection at all — an attacker who can read your storage can read the key beside it.
Deriving a key with PBKDF2
deriveKey takes the passphrase (imported as raw key material), a salt, an iteration count, and a hash, and returns an AES-GCM CryptoKey. Mark it extractable: false so the raw bytes can never be exported.
// Derive a non-extractable AES-GCM key from a user passphrase via PBKDF2.
async function deriveKeyFromPassphrase(
passphrase: string,
salt: Uint8Array,
iterations = 100_000,
): Promise<CryptoKey> {
// Import the passphrase as raw PBKDF2 key material (extractable false).
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false, // extractable: the AES key cannot be read out via exportKey
['encrypt', 'decrypt'],
);
}
// A fresh salt per user, generated once and stored alongside the data.
function newSalt(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(16));
}
The salt must be the same value on every decrypt as it was on encrypt, so persist it (it is not secret). If you regenerate the salt you will derive a different key and every decrypt will fail with OperationError.
The seal / open implementation
With a key in hand, encryption and decryption are two short functions. The output of encryptRecord is self-describing: it bundles the IV, ciphertext, and the parameters a future reader needs, so you never have to remember which IV produced which value.
// A self-describing encrypted record. iv and ct are base64 so the whole thing
// is JSON-serializable for both localStorage strings and IndexedDB values.
interface EncryptedRecord {
v: 1; // schema version, for future algorithm migrations
iv: string; // base64 of the 12-byte IV, unique per write
ct: string; // base64 of the AES-GCM ciphertext (includes the auth tag)
}
function toBase64(bytes: Uint8Array): string {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
function fromBase64(b64: string): Uint8Array {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
async function encryptRecord(key: CryptoKey, value: unknown): Promise<EncryptedRecord> {
const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh per write
const plaintext = new TextEncoder().encode(JSON.stringify(value));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext,
);
return { v: 1, iv: toBase64(iv), ct: toBase64(new Uint8Array(ciphertext)) };
}
async function decryptRecord<T>(key: CryptoKey, record: EncryptedRecord): Promise<T> {
try {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: fromBase64(record.iv) },
key,
fromBase64(record.ct),
);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
} catch (err) {
// AES-GCM throws OperationError when authentication fails:
// wrong key, wrong IV, or tampered ciphertext. Never treat as "empty".
if (err instanceof DOMException && err.name === 'OperationError') {
throw new Error('Decryption failed: wrong key or corrupted data');
}
throw err;
}
}
Because the record is plain JSON with base64 fields, you can drop it straight into a localStorage slot (one string) or store it as a structured value in IndexedDB. The version field v lets you migrate algorithms later without misreading old records.
Persisting sealed records to IndexedDB
IndexedDB is the right home for encrypted data of any meaningful size: it is asynchronous, holds far more than the ~5 MB localStorage cap, and stores structured values without manual stringification. The store holds the EncryptedRecord directly.
// Open a database with a single object store for encrypted records.
function openVault(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('secure-vault', 1);
req.onupgradeneeded = () => {
req.result.createObjectStore('records'); // out-of-line keys
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function putEncrypted(key: CryptoKey, id: string, value: unknown): Promise<void> {
const record = await encryptRecord(key, value);
const db = await openVault();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('records', 'readwrite');
tx.objectStore('records').put(record, id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getDecrypted<T>(key: CryptoKey, id: string): Promise<T | null> {
const db = await openVault();
const record = await new Promise<EncryptedRecord | undefined>((resolve, reject) => {
const tx = db.transaction('records', 'readonly');
const req = tx.objectStore('records').get(id);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return record ? decryptRecord<T>(key, record) : null;
}
This is the foundation for an encrypted offline cache: store ciphertext in IndexedDB, keep the derived key in memory, and re-derive on the next session. For the IndexedDB lifecycle mechanics underneath this — transactions, versioning, eviction — see IndexedDB Architecture & Advanced Patterns.
Non-extractable keys and wrapping
Marking a key extractable: false means crypto.subtle.exportKey and wrapKey will reject it — the raw bytes can never leave the runtime. This is the default you want: even code running in your origin cannot serialize the key and ship it elsewhere. A non-extractable key can still be used to encrypt and decrypt; it simply cannot be read.
But non-extractable keys cannot be persisted as raw bytes either, which is a problem when you want the key to survive a reload without re-prompting. The answer is key wrapping: encrypt the data-encryption key with a separate wrapping key, and store only the wrapped (encrypted) form. wrapKey and unwrapKey do exactly this.
// Wrap an AES key under a wrapping key so it can be persisted as ciphertext.
async function wrapDataKey(
dataKey: CryptoKey, // must be extractable to be wrappable
wrappingKey: CryptoKey, // a separate AES-GCM key, typically from a passphrase
): Promise<EncryptedRecord> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapped = await crypto.subtle.wrapKey(
'raw',
dataKey,
wrappingKey,
{ name: 'AES-GCM', iv },
);
return { v: 1, iv: toBase64(iv), ct: toBase64(new Uint8Array(wrapped)) };
}
async function unwrapDataKey(
record: EncryptedRecord,
wrappingKey: CryptoKey,
): Promise<CryptoKey> {
return crypto.subtle.unwrapKey(
'raw',
fromBase64(record.ct),
wrappingKey,
{ name: 'AES-GCM', iv: fromBase64(record.iv) },
{ name: 'AES-GCM', length: 256 },
false, // the unwrapped data key is non-extractable
['encrypt', 'decrypt'],
);
}
The pattern: a fast, random data-encryption key does the bulk work; the slow passphrase-derived wrapping key only ever wraps and unwraps that one key. Re-encrypting after a passphrase change then costs a single unwrap-then-wrap instead of re-encrypting the whole store. Note the data key here is generated as extractable: true only so it can be wrapped; it is never exported in the clear.
Error handling
AES-GCM fails loudly and that is a feature. The only exception you will routinely see on the decrypt path is a DOMException named OperationError, and it means exactly one thing: authentication failed. The cause is always one of:
- the wrong key was supplied (different passphrase, regenerated salt, or stale key after rotation),
- the IV passed to
decryptdoes not match the one used toencrypt, or - the ciphertext was modified after it was written (disk corruption or tampering).
Never swallow an OperationError as “no data.” Treat it as a hard failure: prompt for the passphrase again, or surface a corruption error. On the encrypt side, a QuotaExceededError can surface when the destination store is full — that is a storage error, not a crypto error, and is covered in How to Handle localStorage QuotaExceededError. Calling crypto.subtle from an insecure context (plain HTTP) throws because crypto.subtle is undefined there — guard with a secure-context check at startup.
function assertCryptoAvailable(): void {
if (!globalThis.isSecureContext || !globalThis.crypto?.subtle) {
throw new Error('Web Crypto requires a secure context (HTTPS or localhost)');
}
}
Browser compatibility
Web Crypto’s SubtleCrypto, AES-GCM, and PBKDF2 are Baseline — available across all current browsers — but two constraints bite in practice: the secure-context requirement, and iOS Safari quirks.
| Feature | Chrome / Edge | Firefox | Safari (desktop) | iOS Safari | Notes |
|---|---|---|---|---|---|
crypto.subtle (AES-GCM, PBKDF2) |
37+ | 34+ | 11+ | 11+ | Secure context only |
wrapKey / unwrapKey |
Yes | Yes | Yes | Yes | Data key must be wrappable |
| Non-extractable keys | Yes | Yes | Yes | Yes | Cannot be exportKey-ed |
| Available in Workers | Yes | Yes | Yes | Yes | Same crypto.subtle interface |
Insecure-context (http://) |
crypto.subtle undefined |
undefined | undefined | undefined | Use HTTPS or localhost |
The recurring iOS Safari caveat is not the cipher but storage durability: Safari’s Intelligent Tracking Prevention can clear IndexedDB after 7 days of inactivity for non-installed sites, so an encrypted vault may simply vanish. Design for re-derivation, not permanence; the eviction mechanics are detailed in The iOS Safari 7-Day Storage Eviction Workaround. Also remember crypto.subtle is unavailable on http:// origins — local development must use localhost or HTTPS.
Performance and scale
Two costs dominate. PBKDF2 iterations are a deliberate slowdown: 100,000 iterations of SHA-256 take tens of milliseconds on a modern laptop and noticeably longer on a budget phone. That cost is the point — it slows an attacker brute-forcing the passphrase — but it is paid on every key derivation. Derive once at login, hold the key in memory, and never re-derive per write. Tune the iteration count to your weakest target device’s UX budget; do not drop below ~100,000.
Bulk encryption with AES-GCM is fast (native, often hardware-accelerated) but not free. Encrypting one large blob is far cheaper than encrypting thousands of tiny records, because each encrypt call has fixed overhead. For large binary payloads, encrypt the bytes directly rather than base64-encoding first — base64 inflates size by ~33% and you pay that on both storage and the cipher. When records routinely exceed a few hundred kilobytes, store the ciphertext as a Blob in IndexedDB rather than a base64 string; see Storing Blobs & Files in IndexedDB.
Encryption is not a defense against XSS
This is the single most important caveat, and it is worth stating bluntly: encrypting browser storage does nothing against a cross-site scripting attacker. XSS gives the attacker JavaScript execution inside your origin. From there, they can call your decryptRecord helper, read your in-memory key, or simply wait for your own code to decrypt and then scrape the plaintext from the DOM. Encryption protects against off-origin threats — disk forensics, other applications on a shared device, a stolen unlocked laptop, another origin’s iframe — not against code running as you.
The correct mental model: encryption raises the cost of physical and cross-origin attacks, while a strict Content Security Policy and keeping bearer credentials out of script-readable storage address script injection. They are complementary layers, not substitutes. If you only do one, do CSP and credential hygiene — covered in Securing Auth Tokens in Browser Storage — because an unencrypted store behind a tight CSP is safer than an encrypted store behind an XSS hole.
Frequently Asked Questions
Why does decrypt throw OperationError instead of returning null?
OperationError from AES-GCM means authentication failed — the wrong key or IV was used, or the ciphertext was altered. GCM cannot distinguish “wrong key” from “tampered data,” so it refuses to return anything. Treat it as a hard error (re-prompt for the passphrase or flag corruption), never as an empty value.
Should my encryption key be extractable or not?
Non-extractable (extractable: false) by default. A non-extractable key can still encrypt and decrypt but can never be serialized via exportKey or wrapKey, so even your own origin’s code cannot dump its raw bytes. Make a key extractable only when you specifically intend to wrap it for persistence, and even then wrap it under a separate key rather than exporting it in the clear.
Is encrypting localStorage enough to protect a JWT?
No. Encryption protects data at rest from off-origin and physical threats, but an XSS attacker runs in your origin and can call your own decrypt helper. A token belongs in an HttpOnly cookie or an in-memory variable, not in encrypted localStorage. See Securing Auth Tokens in Browser Storage.
How many PBKDF2 iterations should I use?
At least ~100,000 with SHA-256, raised as far as your slowest target device’s UX budget allows. More iterations slow brute-force attacks but also slow legitimate key derivation, so derive once at login and cache the key in memory rather than re-deriving on every read or write.
Related
- Storage Security, Encryption & Privacy — the parent guide framing where encryption fits in the overall threat model.
- Encrypting localStorage Values with AES-GCM — the focused recipe for sealing a single localStorage string.
- Securing Auth Tokens in Browser Storage — why credentials need a different defense than encryption.
- Storing Blobs & Files in IndexedDB — storing large ciphertext as Blobs instead of base64 strings.
- IndexedDB Architecture & Advanced Patterns — the persistence layer for encrypted records at scale.