Implementing Argon2 Password Hashing in Wasm
This guide answers one task: compute an Argon2id hash in the browser with parameters that are defensible on a phone as well as a laptop, without freezing the interface and without shipping a multi-megabyte library.
Prerequisites
- [ ] Rust with
wasm-pack, oremccwith the reference C implementation. - [ ] A worker — Argon2 is designed to be slow and memory-hungry.
- [ ] The published Argon2 test vectors, for verification.
- [ ] A clear answer to why the hashing happens on the client rather than the server.
Decide whether this belongs on the client at all
Argon2 exists to make offline attack on a stolen password database expensive. That database is on your server, so the hash that protects it must be computed on your server. Client-side hashing does not replace that, and a design where the client’s output is stored verbatim has simply renamed the password.
There are two legitimate reasons to run it in a browser. The first is deriving a key from a passphrase for client-side encryption, where the key never leaves the device and the server never sees the passphrase — a password manager, an end-to-end encrypted note-taking app. The second is pre-hashing to avoid transmitting the raw password, which is a modest privacy improvement provided the server still hashes what it receives.
If neither applies, the correct implementation is on the server and this page is not the one you need.
Build a minimal module
Rust’s argon2 crate compiles cleanly to WebAssembly and produces a small binary if you keep the
interface narrow and disable default features you do not need.
[dependencies]
argon2 = { version = "0.5", default-features = false, features = ["alloc"] }
wasm-bindgen = "0.2"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
use argon2::{Algorithm, Argon2, Params, Version};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn derive_key(password: &[u8], salt: &[u8], m_kib: u32, t: u32, p: u32, out_len: usize)
-> Result<Vec<u8>, JsValue> {
let params = Params::new(m_kib, t, p, Some(out_len))
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut out = vec![0u8; out_len];
a2.hash_password_into(password, salt, &mut out)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(out)
}
Built this way the module is typically 20–35 kB compressed. Taking the whole argon2 crate with default
features, or leaving panic = "unwind" in place, easily triples that for no benefit.
Choose parameters for the worst device you support
Argon2 has three parameters and they interact. Memory m is the primary defence and the primary cost.
Iterations t multiply time without adding memory. Parallelism p splits the work across lanes, which
helps only if the platform can actually run them concurrently — in a single-threaded browser build it does
not reduce wall-clock time at all.
The published guidance aims at servers. For a browser, the constraint is the slowest device that must be able to log in, and the honest way to choose is to measure:
async function calibrate(targetMs = 500) {
for (const mKiB of [65536, 32768, 19456, 12288, 8192]) {
const t0 = performance.now();
await derive(passwordSample, saltSample, mKiB, 2, 1, 32);
const ms = performance.now() - t0;
if (ms <= targetMs) return { mKiB, t: 2, ms };
}
return { mKiB: 8192, t: 1, ms: null }; // floor
}
Run that once on a range of real devices and pick a fixed configuration — never calibrate per user at runtime, because the parameters must be reproducible to re-derive the same key later. Store the chosen parameters alongside the salt, so a future change does not break existing data.
Keep it in a worker
A 64 MiB, two-iteration hash takes several hundred milliseconds on a laptop and can exceed two seconds on a phone. On the main thread that is a frozen page and, on mobile, sometimes a browser-level unresponsive warning.
// argon-worker.js
import init, { derive_key } from './argon2_wasm.js';
const ready = init();
self.onmessage = async ({ data: { id, password, salt, m, t, p, len } }) => {
await ready;
try {
const key = derive_key(password, salt, m, t, p, len);
self.postMessage({ id, key }, [key.buffer]); // transfer, do not clone
password.fill(0); // clear our copy promptly
} catch (e) {
self.postMessage({ id, error: String(e) });
}
};
Transfer the result rather than cloning it, and zero the password buffer as soon as the hash is computed. Neither is a guarantee — the engine may have copied bytes elsewhere — but both shorten the window during which the secret is sitting in a heap somebody might snapshot.
Expected output
Verify against the published vectors before trusting the build. Argon2id with the reference parameters produces a known value, and a mismatch means something is wrong with the version, the algorithm variant, or the byte order of an input.
const key = await derive(
new TextEncoder().encode('password'),
new TextEncoder().encode('somesalt'),
65536, 2, 1, 32,
);
console.log(toHex(key));
// 09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7
calibration on real hardware
MacBook Pro M2 64 MiB, t=2 → 238 ms
Mid-range Android 64 MiB, t=2 → 1840 ms
Mid-range Android 19 MiB, t=2 → 520 ms ← chosen configuration
Threaded builds and lanes
Argon2’s parallelism parameter divides the memory into lanes that can be filled concurrently. Realising
that concurrency requires a threaded WebAssembly build, which requires cross-origin isolation, which is a
site-wide commitment — so for most applications the practical answer is p = 1 and a memory parameter
tuned for a single lane.
If you do have isolation in place, a threaded build with p matching the worker count reduces wall-clock
time roughly in proportion, which lets you raise the memory parameter for the same user-visible delay.
That is a genuine security improvement: the attacker’s cost scales with memory, so trading concurrency
for a larger m at constant latency strictly favours the defender.
Remember that changing p changes the output. A key derived with p = 1 cannot be reproduced with
p = 4, so this is a decision to make before the first user encrypts anything, and to record in the
parameter string alongside m and t. Migrating later means re-deriving every key, which for
client-side encryption means asking every user to re-enter their passphrase while the old parameters are
still known.
Salts, storage and re-derivation
A salt must be unique per user and unpredictable, and it must be available whenever the key has to be
re-derived. Sixteen random bytes from crypto.getRandomValues is the standard, stored with the encrypted
data rather than kept secret — a salt is not a secret, it exists to make precomputation useless.
Store the parameters with it. A record like argon2id$v=19$m=19456,t=2,p=1$<salt>$ is self-describing, so
raising the parameters later for new users does not strand the old ones. Without that, changing a
parameter silently makes every existing key underivable, which for client-side encryption means the data
is gone.
Gotchas
- Parameters copied from server guidance. 256 MiB is routine on a server and impossible in a tab on a mid-range phone. Measure.
- Parallelism expected to speed things up. In a single-threaded build,
p > 1changes the output but not the wall-clock time. Only a threaded build with real workers benefits. - Argon2i or Argon2d chosen by accident. Argon2id is the general-purpose recommendation; the variant is part of the output, so changing it invalidates every existing hash.
- Salt regenerated on each login. Produces a different key every time. Store it.
RangeErrorallocating memory on mobile. The memory parameter exceeded what the tab can allocate. Catch it and fall back to a lower configuration you have already validated.- Blocking the main thread anyway. The worker was created but the hash ran before
init()resolved, on the page side. Await the module once, at startup.
Performance note
With a 19 MiB, two-iteration, single-lane configuration, the module above takes about 95 ms on an M2 laptop and 480–560 ms on a mid-range Android phone. The module itself is 27 kB compressed and instantiates in under 5 ms, so essentially all of the time is the hash — which is the intended behaviour. Raising memory to 64 MiB roughly triples both figures and is affordable only if your slowest supported device can still finish in under a second.
Frequently Asked Questions
Should I use PBKDF2 through Web Crypto instead? It is native and needs no module, but it is not memory-hard, so an attacker with a GPU gains far more from it than from Argon2. For key derivation from a human passphrase where the derived key protects real data, Argon2id is meaningfully stronger.
How do I show progress during the hash? You cannot get progress from inside a single Argon2 call. Show an indeterminate indicator and keep the duration short enough that it does not matter — which is another reason to calibrate.
Is scrypt a reasonable alternative? Yes, and it has broader library support. Argon2id is the more recent recommendation and has clearer parameter guidance, but a well-configured scrypt is not a mistake.
Related
- Web Crypto vs Wasm for hashing — when the platform is the better answer.
- Writing constant-time code for Wasm — the discipline around the primitive.
- Shrinking Rust Wasm with cargo profiles — how the module got to 27 kB.
← Back to Cryptography & Untrusted Code