Encrypting localStorage Values with AES-GCM

You need to keep a small piece of data in localStorage — a cached profile, a draft, a preference object that happens to contain personal information — but you do not want it sitting on disk in plaintext where any other application on the machine, or anyone with the device, can read it. This guide shows the complete, runnable path: derive a key from a passphrase, encrypt the value with AES-GCM, and pack the result into a single opaque string that drops straight into a localStorage slot. It is the focused recipe behind the broader patterns in Encrypting Browser Storage with Web Crypto.

Packing IV and ciphertext into one localStorage string A flow showing a value encrypted to a 12-byte IV plus ciphertext, concatenated and base64-encoded into a single opaque string written to localStorage, then reversed on read. Value JSON object 12-byte IV ciphertext iv ‖ ct concatenated base64 one string localStorage.setItem opaque base64 on disk

Why plaintext in localStorage is the root cause

localStorage has three properties that make it a poor home for sensitive data, and all three trace back to how the Web Storage specification defines it. It is synchronous — every getItem/setItem blocks the main thread. It is string-only — the spec defines values as DOMString, so anything structured must be serialized. And it is persisted in plaintext — the browser writes those strings to a file in the user’s profile directory with no encryption beyond whatever full-disk encryption the OS happens to provide.

None of that is a bug; it is the contract. The fix is not to abandon localStorage but to change what you store there: instead of a readable string, store an encrypted, opaque one. Because localStorage holds only strings, the encrypted IV and ciphertext — which are raw bytes — must be encoded into a single string, and base64 is the natural choice. The remaining wrinkle is that encryption via crypto.subtle is asynchronous while localStorage is synchronous, so the read and write paths become async functions even though the storage call itself is not.

The complete fix

The plan in four moves: derive a key from the passphrase with PBKDF2, encrypt the JSON value with AES-GCM using a fresh 12-byte IV, concatenate iv ‖ ciphertext and base64-encode the whole thing into one string, then setItem it. Reading reverses each step. The salt and IV are stored in the clear (they are not secret); only the passphrase-derived key is.

// --- key derivation (PBKDF2 → AES-GCM) ---
async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
  const baseKey = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(passphrase),
    'PBKDF2',
    false,
    ['deriveKey'],
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    false, // non-extractable
    ['encrypt', 'decrypt'],
  );
}

// --- bytes <-> base64 helpers ---
function bytesToBase64(bytes: Uint8Array): string {
  let s = '';
  for (const b of bytes) s += String.fromCharCode(b);
  return btoa(s);
}
function base64ToBytes(b64: string): Uint8Array {
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

// --- write: encrypt and pack iv + ciphertext into one localStorage string ---
async function setEncryptedItem(
  storageKey: string,
  value: unknown,
  cryptoKey: CryptoKey,
): Promise<void> {
  const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh per write
  const plaintext = new TextEncoder().encode(JSON.stringify(value));
  const ciphertext = new Uint8Array(
    await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext),
  );
  // Concatenate IV (12 bytes) + ciphertext into one buffer, then base64-encode.
  const packed = new Uint8Array(iv.length + ciphertext.length);
  packed.set(iv, 0);
  packed.set(ciphertext, iv.length);
  localStorage.setItem(storageKey, bytesToBase64(packed)); // sync, but value is opaque
}

// --- read: unpack, decrypt, parse ---
async function getEncryptedItem<T>(
  storageKey: string,
  cryptoKey: CryptoKey,
): Promise<T | null> {
  const stored = localStorage.getItem(storageKey);
  if (stored === null) return null;
  const packed = base64ToBytes(stored);
  const iv = packed.subarray(0, 12);          // first 12 bytes are the IV
  const ciphertext = packed.subarray(12);     // the rest is ciphertext + tag
  try {
    const plaintext = await crypto.subtle.decrypt(
      { name: 'AES-GCM', iv },
      cryptoKey,
      ciphertext,
    );
    return JSON.parse(new TextDecoder().decode(plaintext)) as T;
  } catch (err) {
    if (err instanceof DOMException && err.name === 'OperationError') {
      throw new Error('Wrong passphrase or tampered value');
    }
    throw err;
  }
}

Putting it together — derive the key once per session, then read and write through the helpers:

async function demo(): Promise<void> {
  // Persist the salt once (it is not secret); reuse it for every derivation.
  const SALT_KEY = 'kdf-salt';
  let salt: Uint8Array;
  const storedSalt = localStorage.getItem(SALT_KEY);
  if (storedSalt) {
    salt = base64ToBytes(storedSalt);
  } else {
    salt = crypto.getRandomValues(new Uint8Array(16));
    localStorage.setItem(SALT_KEY, bytesToBase64(salt));
  }

  const key = await deriveKey('correct horse battery staple', salt);

  await setEncryptedItem('profile', { name: 'Ada', email: '[email protected]' }, key);
  const profile = await getEncryptedItem<{ name: string; email: string }>('profile', key);
  console.log(profile); // { name: 'Ada', email: '[email protected]' }
}

Verifying it worked

Open DevTools and select Application > Storage > Local Storage > your origin. The profile entry’s value should be an opaque base64 blob such as qZ3k...== with no readable name or email anywhere in it — that confirms the data is encrypted at rest. The kdf-salt entry will also be base64, which is fine because the salt is not secret.

For an automated check, assert that the stored string contains none of the plaintext and that a round-trip returns the original object:

async function assertEncrypted(key: CryptoKey): Promise<void> {
  const original = { name: 'Ada', email: '[email protected]' };
  await setEncryptedItem('profile', original, key);

  const raw = localStorage.getItem('profile')!;
  if (raw.includes('Ada') || raw.includes('[email protected]')) {
    throw new Error('Plaintext leaked into localStorage');
  }
  const roundTripped = await getEncryptedItem<typeof original>('profile', key);
  if (JSON.stringify(roundTripped) !== JSON.stringify(original)) {
    throw new Error('Round-trip mismatch');
  }
  console.log('OK: value is opaque on disk and decrypts correctly');
}

Edge cases and a fallback

Async crypto, sync storage. localStorage.setItem is synchronous, but crypto.subtle.encrypt returns a Promise. That is why the helpers above are async: you must await the encryption before the synchronous setItem. Never try to encrypt inside a synchronous beforeunload handler — the promise will not resolve before the page tears down. Encrypt on a normal interaction and write the result.

QuotaExceededError on large blobs. localStorage caps at roughly 5 MB per origin, and base64 inflates binary by about 33%, so a 4 MB payload becomes ~5.3 MB of base64 and overflows the quota with a QuotaExceededError. Worse, AES-GCM adds a 16-byte tag and you have the 12-byte IV on top. For anything beyond small objects this approach does not fit. General handling of that exception is covered in How to Handle localStorage QuotaExceededError.

Fallback for big payloads — IndexedDB. When the encrypted value is more than a few hundred kilobytes, store the ciphertext in IndexedDB instead. IndexedDB is asynchronous (matching the async cipher), holds far more than 5 MB, and stores ArrayBuffer/Blob directly so you skip the base64 inflation entirely. The full seal-to-IndexedDB pattern lives in Encrypting Browser Storage with Web Crypto, and the storage trade-off between the two surfaces is laid out in localStorage vs sessionStorage.

Frequently Asked Questions

Why concatenate the IV with the ciphertext instead of storing them separately?

localStorage holds one string per key, and you need both the IV and the ciphertext to decrypt. Packing iv ‖ ciphertext into one base64 string keeps them together so you can never lose the IV that goes with a given value. On read you slice the first 12 bytes back off as the IV.

Does encrypting a localStorage value protect me from XSS?

No. An XSS attacker runs JavaScript in your origin and can call your own getEncryptedItem helper or read the in-memory key. This pattern protects data at rest from off-origin and physical access only. For credentials, use the in-memory and HttpOnly cookie approach in Securing Auth Tokens in Browser Storage.

Why did my value fail to decrypt after I changed the passphrase?

Decryption uses the key derived from the passphrase and salt. A different passphrase — or a regenerated salt — produces a different key, and AES-GCM rejects it with OperationError. Persist the salt once and re-encrypt existing values when the passphrase changes; you cannot decrypt old data with a new key.

Related