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 Rust argon2 crate, 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.

Argon2id parameters and their effect Memory sets how much RAM each derivation needs and is the main defence against GPU attackers, limited by the device's memory. Iterations multiply time linearly. Parallelism adds lanes that run sequentially in single-threaded Wasm, so keep it at 1 unless threads are available. parameter raises attacker cost by browser constraint memory (m) RAM per guess, defeats GPUs linear memory limits on phones iterations (t) time per guess user waits longer parallelism (p) lanes per guess sequential without Wasm threads

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.

Unlocking a vault with a password-derived key The user enters a password. The page loads the stored salt and parameters, starts a worker, and derives the key with Argon2id inside Wasm. The worker posts the key back and is terminated to release its memory. The key unwraps the vault key, which decrypts the data. password entered UI stays responsive load salt + params stored with account Argon2id in worker m=46 MiB, t=1 post key, terminate worker memory released unwrap vault key decrypt data

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.

Argon2id derivation time on a low-end Android phone Milliseconds per derivation in a worker for Argon2id with 19 MiB and two iterations, 46 MiB and one iteration, and 64 MiB and one iteration, single-threaded Wasm. ms per derivation m=19 MiB, t=2 520 ms m=46 MiB, t=1 700 ms m=64 MiB, t=1 980 ms

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.

← Back to Cryptography & Untrusted Code