Secure Local Storage: Encrypting and Key Management in the Browser

Team 7 min read

#browser-security

#encryption

#web-crypto

Introduction

Local storage in the browser is convenient for saving user preferences, cached data, and small offline records. However, by default it stores data in plaintext and is vulnerable to XSS (cross-site scripting) attacks and other browser-based threats. Encrypting data at rest and implementing sound key management practices can significantly reduce the risk, especially for sensitive data like tokens, credentials, or personal information.

This post outlines a practical approach to secure local storage in the browser using modern Web Crypto APIs, a lightweight envelope encryption pattern, and practical guidance on where to store keys and how to manage them across sessions.

Threat model and goals

  • Threats: script access to stored data via XSS, browser extensions with access to storage, and potential data exfiltration.
  • Goals: ensure confidentiality (data is unreadable without the correct key), integrity (data hasn’t been tampered with), and a reasonable separation of concerns between data keys and user credentials.
  • Constraints: browser-based, no server-side key vaults, and respect for user device memory and performance.

Key management concepts

  • Data encryption key (DEK): a random symmetric key used to encrypt the actual content.
  • Key encryption key (KEK): a key used to protect (wrap) the DEK. In a browser, this can be derived from a user password using a password-based key derivation function.
  • Envelope encryption: encrypt the DEK with the KEK, then store the wrapped DEK alongside the ciphertext. This allows changing the KEK (e.g., re-deriving from password) without re-encrypting the data itself.
  • Salt and IV: store a random salt for password-based key derivation and a random IV for each encryption operation to ensure security and uniqueness.
  • Non-exportability and memory: in the browser, keys should be non-exportable when possible, and access should be limited to the user’s session. Use IndexedDB for binary data and avoid leaking keys to localStorage when feasible.

Envelope encryption architecture (browser-friendly)

  • Generate a random DEK for the data.
  • Derive a KEK from the user’s password (PBKDF2 with a high iteration count) and a per-user/ per-item salt.
  • Encrypt the DEK with the KEK (optional, depending on depth of protection).
  • Encrypt the data with the DEK using AES-GCM (provides confidentiality and integrity).
  • Store: ciphertext, IV for data encryption, salt for password derivation, and optional wrapped DEK (if you implemented envelope encryption) in a storage layer (IndexedDB preferred for binary data).

Why envelope encryption? It decouples data encryption from password-derived keys, enabling rotation of the KEK (e.g., password changes) without re-encrypting all data if you wrap/unwrap the DEK. In many browser apps, a simpler approach—deriving a key from the user password and using it directly to encrypt data—may suffice, but envelope encryption provides an extensible path for stronger security.

Implementing with Web Crypto (demo)

Below are compact, browser-friendly snippets illustrating:

  • deriving a key from a password
  • encrypting data with AES-GCM
  • decrypting data
  • basic storage in localStorage (illustrative; IndexedDB is recommended for binary data)

Code assumes a browser environment with Web Crypto API support.

// Helpers: convert between ArrayBuffer and Base64 for storage
function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
  return btoa(binary);
}

function base64ToArrayBuffer(base64) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes.buffer;
}
// Derive a cryptographic key from a password using PBKDF2
async function deriveKeyFromPassword(password, salt) {
  const enc = new TextEncoder();
  const baseKey = await crypto.subtle.importKey(
    'raw',
    enc.encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );
  const key = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: salt,
      iterations: 100000,
      hash: 'SHA-256'
    },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
  return key;
}
// Encrypt data with AES-GCM using a password-derived key
async function encryptWithPassword(data, password) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const key = await deriveKeyFromPassword(password, salt);
  const enc = new TextEncoder();
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv: iv },
    key,
    enc.encode(data)
  );

  // Return data needed to decrypt later
  return {
    ciphertext: arrayBufferToBase64(ciphertext),
    iv: arrayBufferToBase64(iv.buffer),
    salt: arrayBufferToBase64(salt.buffer)
  };
}
// Decrypt data with AES-GCM using a password-derived key
async function decryptWithPassword({ ciphertext, iv, salt }, password) {
  const saltBuf = base64ToArrayBuffer(salt);
  const ivBuf = base64ToArrayBuffer(iv);
  const key = await deriveKeyFromPassword(password, new Uint8Array(saltBuf));
  const plainBuf = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: ivBuf },
    key,
    base64ToArrayBuffer(ciphertext)
  );
  const dec = new TextDecoder();
  return dec.decode(plainBuf);
}
// Simple localStorage-based example (for demonstration; consider IndexedDB for binary data)
async function saveEncryptedNote(noteId, data, password) {
  const encrypted = await encryptWithPassword(data, password);
  const payload = JSON.stringify(encrypted);
  localStorage.setItem(`secure-note-${noteId}`, payload);
}

async function loadEncryptedNote(noteId, password) {
  const payload = localStorage.getItem(`secure-note-${noteId}`);
  if (!payload) return null;
  const encrypted = JSON.parse(payload);
  return await decryptWithPassword(encrypted, password);
}

Notes:

  • This example uses a password-derived key directly for AES-GCM encryption. For stronger security and better key management, consider envelope encryption: generate a random DEK, encrypt your data with the DEK, then encrypt/wrap the DEK with a KEK derived from a password or stored in a hardware-backed store. Store the wrapped DEK along with the ciphertext.
  • In production, prefer IndexedDB for binary ciphertext storage to avoid base64 inflation and to handle larger payloads more efficiently.

Storage strategy: IndexedDB vs localStorage

  • localStorage: simple, synchronous, and string-only. If you store binary data (ciphertext, IV, salt) you’ll need base64 encoding, which increases size and adds a decode step.
  • IndexedDB: designed for binary data, asynchronous, and more scalable. It’s the better long-term choice for encrypted blobs, metadata, and multiple records (e.g., per-note or per-item storage).

Recommended approach:

  • Use IndexedDB to store objects like: { id: “note-1”, ciphertext: "", iv: "", salt: "" }
  • Keep the decryption password in memory only (e.g., from a prompt or a secure UI flow). Do not persist passwords in storage.
  • Consider per-item salts and IVs to ensure uniqueness even for identical plaintexts.

Best practices and pitfalls

  • Use a strong, unique salt for every password-derived key. A new salt per item is acceptable if you re-derive per item.
  • Use a high iteration count (e.g., 100,000 or more) for PBKDF2. SHA-256 is a good default; adjust to balance security and performance.
  • Use AES-GCM (or ChaCha20-Poly1305 where available) to ensure both confidentiality and integrity.
  • Do not store unencrypted sensitive material in localStorage or cookies.
  • Prefer per-item IVs and record them with the ciphertext for proper decryption.
  • If you implement envelope encryption, consider future rotation of KEKs (e.g., after password changes) by re-wrapping DEKs without re-encrypting all data.
  • Be mindful of XSS: encryption protects data at rest, but a successful XSS attack can still access decrypted data in memory. Implement holistic defenses: content security policies, input sanitization, and minimal exposure of secrets in the DOM.

Practical tips for real-world apps

  • Start with a clear data classification: what needs encryption at rest versus what can remain plaintext.
  • Build a small, testable module to handle encryption/decryption and storage, with clear interfaces (e.g., encryptStore(key, value), decryptLoad(key)).
  • Provide a secure onboarding flow for password entry, including a password strength meter and guidance for users.
  • Consider optional WebAuthn-based authentication to unlock the KEK or to bootstrap session keys in a hardware-backed store when available.
  • Regularly review dependencies and browser compatibility for Web Crypto features across your target audience.

Conclusion

Encrypting data stored in the browser and implementing thoughtful key management are essential layers of defense for modern web apps. By combining password-derived keys with AES-GCM, keeping salts and IVs separate, and leveraging envelope encryption where feasible, you can significantly raise the bar against data exposure. While no client-side solution is perfect, a careful design that minimizes exposed secrets, uses secure primitives, and adheres to best practices will deliver practical, robust protection for sensitive browser-stored data.