When a password manager says it's zero-knowledge, it means the service cannot read your passwords. Your data is encrypted before it leaves your device, and the service holds only ciphertext. That's the basic claim, and most major password managers make it honestly.
But zero-knowledge is a spectrum, not a binary. Two vaults can both be genuinely zero-knowledge — meaning neither can read your data without your master password — while one is dramatically more secure under attack than the other. The difference comes down to a single architectural question: is each entry encrypted with a key derived specifically for that entry, or does every entry share the same key?
The single-key model — and why it's the default
The simplest, most common approach works like this: your master password is passed through a key derivation function (KDF) to produce a single 256-bit key. That key encrypts every entry in your vault. Decrypting any single password means loading your master-password-derived key into memory and running AES-GCM.decrypt(entry.ciphertext, entry.iv, key).
This is how most password managers work. It's not wrong. The ciphertext is still AES-256-GCM, the key is still derived from your master password, and the server genuinely cannot read your data without knowing your master password. For most threat models, this is sufficient.
But there's a structural property of this approach that becomes meaningful under certain attacks: if an attacker recovers your single vault key, they recover everything simultaneously. Every password in your vault, from your email account to your banking credentials, is decryptable with a single key material in memory.
Per-entry key derivation — what it actually means
Per-entry key derivation adds a layer between your master key and each vault entry. Instead of encrypting entry A directly with your master key K, you derive a per-entry key K_A from K and some entry-specific material, then encrypt A with K_A. Entry B gets its own K_B, derived independently.
In HexVault, we implement this using HKDF (HMAC-based Key Derivation Function, RFC 5869). When a vault entry is created or updated, the client generates a random 256-bit entry salt. This salt is stored alongside the ciphertext (it isn't secret — its job is to make K_A unique, not to keep it hidden). The per-entry key is then derived as:
async function deriveEntryKey(masterKey, entrySalt) { // Import master key as base keying material for HKDF const hkdfKey = await crypto.subtle.importKey( 'raw', masterKey, 'HKDF', false, ['deriveKey'] ); // Derive a unique AES-GCM key for this specific entry return await crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: entrySalt, // 256-bit random, unique per entry info: new TextEncoder().encode('HexVault-entry-v2') }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, // non-extractable ['encrypt', 'decrypt'] ); }
The info parameter binds the derived key to this specific context — it can't be used as, say, a signing key. The entry salt makes every derived key unique even if two entries have identical content. And because the resulting CryptoKey is marked non-extractable, no JavaScript running in the page can read the raw key bytes — only the Web Crypto API can use it.
What this changes under attack
Consider three realistic attack scenarios and how the two models respond.
Scenario 1: Server breach, database dump
An attacker compromises the server and dumps the database. They now have every user's encrypted vault data. In both models, decrypting any vault requires cracking the master password — the server never held the key. The attacker's next step is offline brute-force: try millions of passwords against the KDF.
Both models resist this equally well at first. The difference emerges when the attacker actually cracks a master password. With the single-key model, cracking one password gives them the key to decrypt every entry in that vault simultaneously. With per-entry derivation, cracking the master password gives them the ability to derive individual entry keys — but each entry requires a separate HKDF derivation with its own salt. The computational cost is negligible for legitimate use (milliseconds per entry), but it makes certain partial-access attacks structurally harder.
Scenario 2: Memory extraction
A more sophisticated attacker manages to read process memory on the client device — through a compromised browser extension, a malicious script, or a memory inspection tool. They're looking for key material.
With the single-key model, one key in memory unlocks the entire vault. With per-entry derivation, the master key is in memory briefly during login, but individual entry keys are derived on demand and are non-extractable — the raw bytes never appear in JavaScript memory. An attacker reading memory at the wrong moment might get a single entry key rather than the entire vault key.
This isn't a silver bullet — the master key is still in memory during the derivation window — but it reduces the value of a successful memory read by narrowing the window during which the highest-value material is accessible.
Scenario 3: KDF weakness or future cryptanalysis
Cryptographic algorithms don't break overnight, but the history of applied cryptography is full of "that was fine until it wasn't." If a weakness were discovered in Argon2id or AES-GCM that allowed partial plaintext recovery from ciphertext — a wildly unlikely but nonzero risk — per-entry key derivation limits the blast radius. A partial break that recovers one entry's key doesn't recover the others, because each entry's key was derived with different material.
The performance cost — and why it's acceptable
The honest trade-off is performance. Per-entry key derivation means an HKDF derivation for every encrypt and decrypt operation. On modern hardware, a single HKDF-SHA-256 derivation takes roughly 0.1–0.5ms. For a vault with 200 entries, loading all passwords requires 200 derivations — potentially 100ms of pure key work, on top of 200 AES-GCM decryptions.
In practice this is imperceptible. Vault loads are dominated by network round-trips and DOM rendering, not cryptographic operations. The derivation cost only becomes relevant if you're decrypting thousands of entries in a tight loop, which is not a normal user workflow.
The more meaningful cost is implementation complexity. Per-entry derivation requires:
- Generating and storing an entry salt for every credential (extra DB columns, extra bytes in every request)
- Managing key version numbering so legacy entries without salts can still be decrypted during migration
- A migration path for users on older vault versions, running silently in the background
- More careful testing — the encrypt/decrypt round trip must be verified against stored salts, not just against a known key
This is why most password managers don't implement it. It adds engineering overhead for a security improvement that most users will never directly perceive. The incentive structure in commercial software doesn't reward it.
Verifying your own vault
If you're using HexVault (or any password manager that claims per-entry key derivation), you can verify it's actually happening. Open DevTools, go to the Network tab, and watch a request to /api/passwords. The response for each entry should include both an iv field and an entry_salt field. If you see only iv with no salt, the manager is using a single key for all entries.
{
"id": 1247,
"name": "GitHub",
"encrypted_password": "base64-encoded-ciphertext...",
"iv": "base64-encoded-96-bit-IV...",
"entry_salt": "base64-encoded-256-bit-salt...", // this should exist
"key_version": 2 // v2 = HKDF per-entry
}You can also inspect the source directly. In HexVault's unminified static/script.js, search for deriveEntryKey or HKDF. The derivation call is verbatim in the source — no obfuscation, no minification.
How the major players compare
To be fair about this: all the major password managers listed below implement genuine zero-knowledge encryption. The distinction here is specifically about per-entry key isolation, not about whether they're trustworthy overall.
| Manager | Key model | KDF | Entry salt | Verifiable source |
|---|---|---|---|---|
| HexVault | Per-entry HKDF | Argon2id (64 MB) | Yes | Unminified |
| Bitwarden | Single vault key | PBKDF2 or Argon2id | No | Open source |
| 1Password | Single vault key + Secret Key | PBKDF2 | No | Partial docs |
| LastPass | Single vault key | PBKDF2 | No | No |
| Dashlane | Single vault key | Argon2d | No | Partial docs |
Implementation notes for the technically inclined
A few things we learned building this that aren't obvious from the HKDF spec:
The info parameter matters more than it looks. HKDF's info is domain-separation material — it binds the derived key to a specific purpose. We use "HexVault-entry-v2", which means a key derived with this context cannot accidentally be used in any other HKDF derivation in our system, even if the same base key material were somehow reused. Don't leave info empty.
Version your key derivation. We use a key_version field on every entry. Version 1 entries were encrypted directly with the master key (no per-entry derivation — legacy from before we shipped this). Version 2 uses HKDF with an entry salt. When a v1 entry is next written, it's silently migrated to v2. This lets us evolve the cryptography without a forced migration that locks users out.
The entry salt is not a secret, but it must be random. Its job is to make each derived key unique, not to add entropy to the master key. A predictable or reused salt would defeat the purpose entirely. We use crypto.getRandomValues(new Uint8Array(32)) — 256 bits of OS-level randomness. Never use an entry ID, timestamp, or anything predictable as the salt.
Non-extractable CryptoKeys are your friend. Marking the derived key as extractable: false in the importKey and deriveKey calls means JavaScript code in the page — including any injected scripts from a compromised extension — cannot call exportKey and read the raw key bytes. The key exists in memory but is opaque to the JS layer. This is a free mitigation and there's no good reason not to use it.
The takeaway
Per-entry key derivation isn't a marketing claim — it's a specific, verifiable cryptographic property. It doesn't make a vault invulnerable, but it does change the geometry of what an attacker gets if they succeed in a partial compromise. One entry's key doesn't bleed into the next.
The architecture we described here is exactly what runs in HexVault's production code. You don't have to take our word for it — the derivation is in unminified JavaScript, readable in any browser's DevTools. We think that combination of per-entry isolation and verifiable source is what "zero-knowledge" should actually mean.
deriveEntryKey and encryptPassword functions are in static/script.js — search for deriveEntryKey in DevTools → Sources. The security page walks through the full architecture with the actual code excerpts.