What password stretching standards recommend, and what we ship
OWASP recommends Argon2id first for password storage and reserves a 600,000 iteration figure for PBKDF2. Violet ships PBKDF2-SHA256 at 200,000 iterations in the browser, because the Web Cryptography API offers no memory-hard alternative and the client ships without dependencies. This post sets the current standards next to the exact shipped parameters, walks the threat math in plain words, and names the conditions under which the parameters change.
Why we publish our exact parameters
Violet encrypts personal data with a key derived from a passphrase, so the key derivation function and its cost settings are part of the product's security, not an implementation detail. Honest design means naming the actual numbers, comparing them to what the standards bodies recommend, and saying plainly where we fall short and why. Violet is pre-launch. This page describes code that exists in the repository today, not a marketing abstraction of it.
What the standards recommend
Two documents cover most of what people mean by the standard here. The OWASP Password Storage Cheat Sheet gives concrete parameter choices for storing password verifiers. NIST SP 800-132 covers password-based key derivation, which is closer to what Violet's client does: it derives an encryption key from a passphrase rather than storing a verifier. The primitives are the same; the guidance differs slightly in emphasis.
| Source | What it says |
|---|---|
| OWASP Password Storage Cheat Sheet | Use Argon2id first, with a minimum of 19 MiB of memory, 2 iterations, and 1 degree of parallelism. Use scrypt where Argon2id is unavailable. Where PBKDF2 is required, for example for FIPS-140 compliance, use PBKDF2-HMAC-SHA256 with 600,000 iterations. |
| NIST SP 800-132 | For password-based key derivation: a random salt of at least 128 bits, and an iteration count as large as the environment can tolerate, with 1,000 as the bare minimum. |
| RFC 9106 | Defines Argon2 and recommends the Argon2id variant, which resists both GPU cracking and side-channel attacks. |
| RFC 8018 | Defines PBKDF2 itself. The iteration count is the cost knob, to be set as high as practical. |
| RFC 7914 | Defines scrypt, a memory-hard KDF that predates Argon2. |
The ranking exists because Argon2id and scrypt are memory-hard: each guess costs the attacker real memory as well as compute, which blunts the advantage of GPUs and custom hardware. PBKDF2 is compute-only, so specialized hardware parallelizes it well. That is the qualitative gap the standards are pointing at, and no iteration count closes it.
What Violet actually ships
Three code paths in the repository stretch a secret. Here they are, with their exact parameters and where each one runs.
| Path | KDF | Parameters | Where it runs |
|---|---|---|---|
| Cloud sync content key | PBKDF2-SHA256 | 200,000 iterations, random 128-bit per-account salt, derives a non-extractable AES-256-GCM key | In the browser only; the key never leaves the client |
| Sign-in password hash | PBKDF2-SHA256 | 200,000 iterations, separate random 128-bit salt, constant-time comparison | On the server, once per sign-in |
| Desktop journal vault | scrypt | N 32768, r 8, p 1, about 32 MiB of memory per derivation | In the desktop runtime via node:crypto |
The sync path is the one this post is about. The derived key encrypts content before it leaves the device, and the server stores only an opaque nonce-plus-ciphertext envelope. This is the actual derivation code from the client:
const PBKDF2_ITERS = 200_000; // >= 200k per the spec
export async function deriveContentKey(passphrase, kdfSaltB64) {
const baseKey = await crypto.subtle.importKey(
"raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: b64ToBytes(kdfSaltB64),
iterations: PBKDF2_ITERS, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false, // non-extractable: the key cannot be read back out
["encrypt", "decrypt"],
);
}
The salt is 16 random bytes generated per account, which meets the 128-bit floor in NIST SP 800-132. The key is created non-extractable, so even the client's own code cannot export it and send it anywhere.
Why PBKDF2 in a browser
Violet's web client ships without dependencies: no packages, no bundler, and a strict content security policy that blocks external scripts. Under that rule, the only key stretching available is what the browser provides natively, and the Web Cryptography API's deriveKey supports PBKDF2 and HKDF but not Argon2 or scrypt. HKDF is not a password KDF, so PBKDF2 is the only stretching option on the table.
Shipping Argon2id in a browser today means shipping a WebAssembly build of it: a third-party dependency we would have to vet, pin, and keep patched, in a codebase whose rule is that there are none. So the honest statement is this: Argon2id is the better KDF, and we do not ship it here because we cannot do so without dependencies. Where the runtime does offer a memory-hard KDF we use one; the desktop journal vault derives its keys with scrypt from node:crypto. PBKDF2 in the browser is a constraint we are naming, not a choice we are dressing up as equivalent.
Why 200,000 and not 600,000
OWASP's 600,000 figure targets server-side password storage, where the server pays the derivation cost once per login on server hardware it controls. Violet's heaviest PBKDF2 use runs on the user's own device, every time a session derives the content key, including on modest phones. The spec set 200,000 as the floor for that budget, and that is what ships.
Even granting the different context, 200,000 is below OWASP's PBKDF2 number and we say so plainly rather than argue the guidance away. Two facts keep the gap in proportion. First, the difference between 200,000 and 600,000 is a factor of three, which in guessing terms is about 1.6 bits of extra work. Second, the jump the standards actually care about is memory-hardness, and tripling an iteration count does not buy any of it. Raising the count is cheap margin and we expect to take it; it is not the upgrade that matters most.
The KDF is also the second line of defense in this design, not the first. The server never holds the content key or the plaintext, so the derivation cost only becomes relevant to an attacker who has already obtained ciphertext. That does not excuse a weak parameter, but it does define what the parameter is protecting.
The threat math in plain words
- Stretching multiplies the cost of every guess. At 200,000 iterations, one passphrase guess costs an attacker 200,000 hash computations instead of one. That is about 17.6 bits of added work, since 2 to the power 17.6 is roughly 200,000.
- The salt removes shortcuts. Because every account has its own random 128-bit salt, an attacker cannot precompute a table of passphrases once and reuse it, and identical passphrases on two accounts produce unrelated keys.
- Stretching only matters after a leak. The derivation cost is irrelevant until an attacker holds ciphertext, from a stolen device or a breached server. A breach of Violet's server yields opaque envelopes and salted PBKDF2 password hashes, and turning either into content requires guessing passphrases offline at 200,000 hashes per guess.
- Stretching cannot rescue a weak passphrase. If a passphrase appears on a common-password list, an attacker needs only thousands of guesses, and multiplying a tiny number by 200,000 still yields a feasible attack. The passphrase supplies the security; the KDF buys margin.
When we would revisit
- Browsers ship a memory-hard KDF. If the Web Cryptography API or a successor adds Argon2 or scrypt, we migrate to it. That is the change that actually closes the gap the standards describe.
- Guidance moves. If OWASP or NIST publish new figures, the parameters get re-examined against them, in public, the same way this post does.
- Before general availability. Violet is pre-launch, which is the cheapest possible time to migrate: raising the iteration count or changing the KDF requires a client-side re-encryption pass, and the fewer accounts exist, the smaller that migration is. The sync protocol currently pins the count in code, so we treat any raise as a scripted migration, not a config flip; the journal vault already stores its cost parameters alongside the salt for exactly this reason.
The summary is short. The standards say: memory-hard first, and if you must use PBKDF2, use a lot of iterations. We ship PBKDF2-SHA256 at 200,000 iterations in the browser because that is the strongest stretching the platform offers without dependencies, it sits below OWASP's PBKDF2 figure by a factor of three, the passphrase itself carries most of the real security either way, and the parameters are written down here so they can be held against us when the constraints change.
Questions
Is 200,000 iterations below the OWASP recommendation?
Yes. The OWASP Password Storage Cheat Sheet lists 600,000 iterations for PBKDF2-HMAC-SHA256 in password storage. Violet ships 200,000 in a different context, client-side key derivation on user hardware, but the number is still a factor of three lower, which is about 1.6 bits of guessing work. We state the gap instead of arguing it away, and raising the count is one of the named revisit paths.
Why not ship Argon2id compiled to WebAssembly?
The browser has no native Argon2, so shipping it means adding a third-party WebAssembly dependency to a client whose rule is zero dependencies and a strict content security policy. A cryptographic dependency would need vetting, pinning, and ongoing patching. If browsers add a memory-hard KDF natively, migration to it is the first revisit trigger.
Does a higher iteration count make a weak passphrase safe?
No. Iterations multiply the cost of each guess, and 200,000 iterations add about 17.6 bits of work. A passphrase from a common-password list falls in thousands of guesses regardless, because multiplying a tiny search space by any iteration count still leaves a feasible attack. Choose a long passphrase; the KDF only buys margin on top of it.
What would an attacker get by breaching the sync server?
Account emails, salted PBKDF2-SHA256 password hashes, per-account KDF salts, and opaque encrypted envelopes of nonce plus ciphertext. No content keys and no plaintext content are stored server-side; the content key is derived in the browser, is non-extractable, and never leaves the client. Reading content would require guessing passphrases offline against the KDF.
Related pages
Sources
- OWASP Password Storage Cheat Sheet
- NIST SP 800-132, Recommendation for Password-Based Key Derivation
- RFC 8018, PKCS #5: Password-Based Cryptography Specification (PBKDF2)
- RFC 9106, Argon2 Memory-Hard Function for Password Hashing
- RFC 7914, The scrypt Password-Based Key Derivation Function
- MDN, SubtleCrypto.deriveKey (supported algorithms)