What the “encrypt‑then‑store” shortcut looks like

Many front‑end engineers reach for the browser’s built‑in storage APIs (localStorage, sessionStorage, or IndexedDB) when they need to keep user data offline. The “good‑enough” pattern that spreads across tutorials is to generate a key with window.crypto.subtle.generateKey, encrypt the payload, and write the ciphertext straight into IndexedDB. On the surface it appears to satisfy both persistence and confidentiality, but the design silently collapses under realistic threat models.

The hidden flaw: keys live in the same origin

The browser sandbox isolates data per origin, yet it does not separate cryptographic material from application code. If an attacker can inject JavaScript—through a supply‑chain compromise, a malicious third‑party script, or a reflected XSS— they gain immediate access to the raw CryptoKey object. Once the key is in memory, the attacker can decrypt any record stored in IndexedDB, rendering the encryption meaningless.

// Bad: generate a key and keep it in memory forever
async function initCrypto() {
  const key = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true,
    ["encrypt", "decrypt"]
  );
  // Store the key reference in a global variable
  window.appKey = key;
}

The snippet above is a typical starting point in many blog posts. The window.appKey reference survives page reloads (because the script re‑runs) and stays reachable to any script that can run in the same origin. Even a benign third‑party analytics library that inadvertently exposes the global can become a conduit for key exfiltration.

Why the browser can’t protect you

Modern browsers do isolate the storage layer from the JavaScript runtime, but they do not enforce a “key‑only‑in‑hardware” rule for Web Crypto. The CryptoKey object is a handle that points to a secret stored in the browser’s internal keystore; however, the handle itself is a JavaScript object. Any code that receives the handle can call crypto.subtle.decrypt with it. The browser does not differentiate between “trusted” and “untrusted” script when it comes to using that handle.

// An attacker‑controlled script can now decrypt everything
async function stealData() {
  const key = window.appKey; // Grab the global reference
  const db = await openDatabase();
  const encrypted = await db.get('user‑profile');
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: storedIv },
    key,
    encrypted
  );
  console.log('Stolen data:', new TextDecoder().decode(plaintext));
}

The above code demonstrates the “hidden internals” of the attack: once the key is exposed, the encrypted blob is trivial to decrypt. The real security problem is not the encryption algorithm—it is the key lifecycle.

Designing a safer architecture

The correct approach moves the secret‑handling responsibility out of the browser. Instead of generating a long‑lived key client‑side, the app should request a short‑lived data‑encryption key from a back‑end that enforces authentication and rate‑limits. The back‑end can embed the key in a JSON Web Encryption (JWE) token, which the client uses only for the duration of the session. After the session ends, the key is discarded, and the encrypted data in IndexedDB becomes effectively unreadable without a fresh token.

// Safer: fetch a per‑session key from the server
async function fetchSessionKey() {
  const response = await fetch('/api/session-key', {
    credentials: 'include',
    method: 'POST',
  });
  const { jwe } = await response.json();
  // Decrypt the JWE with the browser’s built‑in key (derived from a password)
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(userPassword),
    "PBKDF2",
    false,
    ["deriveKey"]
  );
  const sessionKey = await crypto.subtle.decrypt(
    { name: "RSA-OAEP" },
    keyMaterial,
    base64ToArrayBuffer(jwe)
  );
  return await crypto.subtle.importKey(
    "raw",
    sessionKey,
    { name: "AES-GCM" },
    false,
    ["encrypt", "decrypt"]
  );
}

Notice that the key never lives longer than the session, and it is never attached to a global variable. The back‑end can also rotate keys on demand, invalidating any stale ciphertext stored in the client. If the attacker captures the IndexedDB file from the user’s device, they still need a valid JWE token to recover the plaintext.

Implementation checklist

Below is a concise checklist that you can embed into your development workflow:

  • Never store CryptoKey objects in globals or persistent variables.
  • Use short‑lived, server‑issued encryption keys bound to a user session.
  • Encrypt only the minimal data required for offline use; keep the rest server‑side.
  • Apply authenticated encryption modes (e.g., AES‑GCM) and verify the tag before use.
  • Implement CSP and Subresource Integrity to limit script injection vectors.
  • Audit third‑party scripts for accidental exposure of global handles.

Security and Best Practices

Even with a server‑driven key model, you must still protect the transport layer. Enforce HTTPS everywhere, enable HSTS, and consider certificate pinning for high‑value APIs. On the client, leverage the Secure and HttpOnly flags for cookies that carry session identifiers, and store the session key only in memory (never in IndexedDB or localStorage). Finally, adopt a regular security audit cadence that includes automated scanning for global variables that reference cryptographic objects.

If you must persist data for offline use, consider using the Encrypted Media Extensions (EME) API together with a DRM‑backed key store, which isolates the key in a hardware‑backed module when the platform supports it. While not universally available, it provides a stronger guarantee than pure JavaScript‑managed keys.

“Encryption that lives in the same trust domain as the attacker’s code offers no real protection; true confidentiality requires a separation of duties.”

Conclusion

Storing encrypted blobs in IndexedDB is tempting, but without a disciplined key management strategy it becomes a false sense of security. By moving key generation to a trusted back‑end, limiting key lifespan, and rigorously auditing script inclusion, you turn “encrypt‑then‑store” from a security trap into a genuine data‑privacy feature. The extra complexity is modest compared to the cost of a data breach that could have been avoided with a proper separation of responsibilities.

Remember: the browser is a powerful platform, but it is not a vault. Treat it as a transient execution environment, and keep the long‑term secrets where you can enforce stricter controls.