Deriving Keys from Passwords in Wasm
This page answers one task: an end-to-end encrypted or local-first application derives an encryption key from the user’s password in the browser, and you need parameters strong enough to slow down attackers with GPUs, yet fast enough that a user on a five-year-old phone is not waiting ten seconds — or crashing the tab.
Prerequisites
- [ ] An Argon2id implementation in Wasm (libsodium’s
crypto_pwhash, the Rustargon2crate, or a C build), or scrypt. - [ ] A dedicated worker to run derivation.
- [ ] Representative devices (a low-end Android phone, an iPhone, a laptop) for measurement.
What the parameters control
Password-based key derivation is deliberately expensive: each guess an attacker makes must repeat the same work, so making one derivation take half a second limits an attacker to a few guesses per second per core. Argon2id has three cost parameters. Memory (m, in KiB) is the size of the array each derivation fills and reads — the main defence against GPU and ASIC attackers, whose advantage shrinks when each guess needs lots of memory. Iterations (t) is the number of passes over that memory. Parallelism (p) is the number of independent lanes, which native implementations run on several threads; in single-threaded Wasm, lanes run sequentially, so higher p only adds time for the defender.
PBKDF2, the only password KDF in WebCrypto, has just an iteration count and no memory cost, which makes it far cheaper for GPU attackers. That is the main reason to run Argon2id or scrypt in Wasm instead of using WebCrypto’s built-in option.
Step 1 — measure on real devices
Benchmark derivation time for a grid of parameters on the weakest device you support. Run the benchmark in a worker:
// kdf-bench-worker.js
import { argon2id } from "./pkg/kdf.js"; // Rust argon2 crate via wasm-bindgen
self.onmessage = ({ data }) => {
const results = [];
for (const m of [19456, 47104, 65536, 131072]) { // KiB: 19 MiB … 128 MiB
for (const t of [1, 2, 3]) {
const t0 = performance.now();
argon2id(new TextEncoder().encode("benchmark"), new Uint8Array(16), m, t, 1);
results.push({ m, t, ms: performance.now() - t0 });
}
}
self.postMessage(results);
};
Record time and whether allocation succeeded. On low-end phones, 128 MiB may fail or push the tab towards being killed; on laptops, it is routine.
Step 2 — choose parameters per device class
Pick a target time — 300 to 1,000 ms is common for login or unlock — and choose the largest memory that meets it on the weakest supported device, then adjust iterations. OWASP’s current guidance lists equivalent Argon2id configurations such as m=47104 (46 MiB), t=1 or m=19456 (19 MiB), t=2; check the current recommendations rather than copying old numbers. If the user base spans very different devices, you can choose parameters per device class at account creation (measured on the device), but the parameters must then be stored with the account, since every later derivation must use the same ones.
Step 3 — run derivation in a worker and keep memory in check
Derivation blocks for hundreds of milliseconds; do it in a worker so the UI can show progress and stay responsive. Argon2’s memory is allocated inside the module’s linear memory, which then never shrinks: after one derivation with 64 MiB, the worker’s memory stays at least that large. For a one-time unlock, terminate the worker after deriving the key, which releases its memory entirely; keep a long-lived worker only if derivations are frequent.
Step 4 — store salt and parameters with the derived data
Every derivation needs the same salt and parameters. Generate a random 16-byte salt per user (or per vault) at creation and store it, together with the algorithm name, version, m, t and p, next to the encrypted data or on the server. Use the derived key to wrap (encrypt) a randomly generated data key rather than encrypting data with the password-derived key directly — then changing the password or upgrading parameters means re-wrapping one small key, not re-encrypting everything.
Step 5 — upgrade parameters over time
Hardware gets faster; parameters chosen today will be weak in a few years. Store a parameter version, and when a user unlocks successfully with old parameters, derive a new key with current parameters and re-wrap the data key. Users never notice beyond one slightly slower unlock. Keep the code path for old parameters until no accounts use them.
scrypt as an alternative
scrypt is also memory-hard and widely implemented. Its parameters are N (CPU/memory cost, a power of two), r (block size) and p; memory is roughly 128 × N × r bytes. It is a reasonable choice for compatibility with systems already using it; for new designs, Argon2id is the current recommendation. Both compile well to Wasm; neither benefits much from SIMD because they are memory-bound.
Threads and SIMD
With a threaded Wasm build and cross-origin isolation, Argon2id’s lanes can run on several workers sharing memory, and p can be raised for the same wall-clock time — matching how native implementations work. That adds deployment requirements (isolation headers) for a moderate gain. SIMD helps Argon2’s compression function somewhat. Measure both before adopting; for most applications, a single-threaded build with p=1 is the pragmatic choice.
Why not derive on the server?
If the server derives keys from passwords, it sees the password, which defeats end-to-end encryption. Client-side derivation keeps the password on the device. Servers that authenticate users still need their own password hashing (with their own parameters and hardware), which is a separate concern from deriving an encryption key on the client.
Handling the password and key in memory
The password and the derived key are the most sensitive values in the system, and JavaScript gives limited control over memory. Pass the password to the
worker as bytes (TextEncoder output) rather than a string, since strings are immutable and may be interned or copied by the engine; after derivation,
overwrite the byte array with zeros and drop references. Inside the module, the Rust argon2 crate can work with zeroize so internal buffers are wiped
when dropped, and the memory block Argon2 used is part of linear memory that disappears entirely when the worker is terminated — another reason to use a
short-lived worker. Return only the derived key (or better, the unwrapped data key) to the page. None of this protects against code running in the same
page, such as an XSS payload, which could read the password from the input field directly; content security policy and careful dependency management
matter more than memory hygiene for that threat.
Normalising passwords
Users type the same password on different devices with different keyboards and input methods, and Unicode allows several byte sequences for what looks
like the same character — “é” as one code point or as “e” plus a combining accent. If one device produces NFC and another NFD, the derived keys differ and
the user is locked out. Normalise passwords with password.normalize("NFKC") (or NFC, consistently) before encoding them to bytes, and document the choice
in the parameter record, so every client derives from the same bytes.
Expected output
On a low-end Android phone, Argon2id with m=46 MiB, t=1, p=1 takes about 700 ms in a worker; on a laptop, about 150 ms; parameters, salt and version are stored with each vault; the worker is terminated after unlock, releasing its memory; and accounts created with older parameters are upgraded on their next successful unlock.
Gotchas
- Using PBKDF2 because it is built in. No memory hardness. Prefer Argon2id or scrypt in Wasm.
- Parameters chosen on a developer laptop. Phones take several times longer. Measure on the weakest device.
- Raising p in single-threaded Wasm. It only slows the defender. Keep p=1 without threads.
- Long-lived workers after large derivations. Memory stays grown. Terminate after use.
- Encrypting data directly with the derived key. Password changes require re-encryption. Wrap a data key.
Performance note
On the same low-end phone, raising memory from 19 MiB to 64 MiB roughly tripled derivation time; raising iterations from 1 to 3 at 19 MiB had a similar cost but much less effect on GPU attackers, which is why memory is usually the parameter to spend time on.
Frequently Asked Questions
Is 700 ms too slow? For unlock or login, users tolerate it; for frequent operations, derive once and keep the unwrapped key in memory for the session.
Should the salt be secret? No — it must be unique, not secret.
Can I cache the derived key? In memory for a session, yes; persisting it defeats the purpose unless wrapped by another secret.
Does WebCrypto support Argon2? Not currently; it offers PBKDF2 and HKDF.
Why can a user unlock on one device but not another with the same password? Unicode normalisation can differ between input methods; normalise with NFKC or NFC before deriving, on every client.
Related
- Implementing Argon2 password hashing in Wasm — building the Argon2 module.
- Running libsodium in the browser —
crypto_pwhash. - Encrypting files client-side with Wasm — using the key.
- Encrypting a browser database at rest — another use.
← Back to Cryptography & Untrusted Code