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
CryptoKeyobjects 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.