DR-004
Hash passwords with argon2id and upgrade bcrypt hashes on sign-in
Status: Accepted · October 2026 · Author: Rin Huang
Decision
New password hashes use argon2id with 19 MiB of memory, 2 passes and 1 lane (the first configuration the OWASP Password Storage Cheat Sheet recommends), through @node-rs/argon2. Existing bcrypt hashes still verify, and each is replaced with an argon2id hash the next time its owner signs in successfully. The seed's demo accounts are hashed with argon2id using deterministic salts, so data/seed.db stays byte-for-byte reproducible.
Context
The 2022 API hashed with bcrypt, reading the cost from process.env.SALT. The first revival used bcryptjs at cost 10. bcrypt is still acceptable, but it is not memory-hard, so guessing at scale on GPUs is cheap compared with argon2id. Accounts already exist on the live database, so any change has to keep their passwords working.
Options considered
- Keep bcryptjs, cost 10. Nothing to migrate; weakest against GPU guessing.
- scrypt from
node:crypto. Memory-hard, built in, no dependency. A reasonable choice; argon2id is the more commonly recommended default today. - argon2id through
argon2(node-gyp). Needs a native build in CI and on Vercel. - argon2id through
hash-wasmor@noble/hashes. No native code, but slower, so a given time budget buys weaker parameters. - argon2id through
@node-rs/argon2(chosen). Prebuilt binaries for Linux and macOS, already on Next.js's list of server-external packages, so it works on Vercel without configuration.
Why
argon2id makes each guess cost memory as well as time, which is what slows down large-scale cracking. The upgrade-on-sign-in path means no member has to reset a password and no plain password is ever needed outside the moment someone types it. The update that writes the new hash is conditional on the old hash still being there, so it can't overwrite a password changed in the meantime.
What happened
Benchmark (pnpm bench:hash, results in web/content/benchmarks/password-hashing.json): 40 paired rounds after 3 warm-up rounds, one fresh random password per round hashed by both schemes, alternating which ran first, on an Apple M4 laptop with Node 26.
| Median per hash | 95% interval (bootstrap, 2,000 resamples, seed 20220107) | |
|---|---|---|
| bcryptjs, cost 10 (before) | 61.5 ms | 58.7 to 63.1 ms |
| argon2id, 19 MiB, t = 2 (now) | 11.0 ms | 10.2 to 11.7 ms |
| Ratio of medians, argon2id / bcrypt (paired) | 0.18 | 0.17 to 0.19 |
argon2id was faster in all 40 rounds (Wilson 95% interval for that share: 91% to 100%).
How to read this, honestly: the comparison is confounded by implementation. bcryptjs is pure JavaScript and @node-rs/argon2 is native Rust, so the result says this app's sign-in got cheaper in CPU time, not that argon2id is faster than bcrypt in general. The real cost moved from time to memory: each hash holds 19 MiB, so 50 sign-ins at the same moment on one instance would need about 1 GB. The numbers come from a laptop, not from Vercel's functions, and say nothing about them.
The integration tests check that the seeded demo accounts are argon2id, that a bcrypt hash upgrades on the first good sign-in and not on a failed one, and that the upgrade is audited (auth.password_rehashed).
What I'd change
- Run the same benchmark on a Vercel function: different hardware, and the place where the result matters.
- Add a native bcrypt column to the benchmark, so the scheme and the implementation can be told apart.
- With this much headroom, raise the cost above OWASP's minimum (more memory or another pass) and re-measure, rather than keeping the minimum.
- Add a server-side pepper from an environment variable, so a stolen database alone is not enough to start guessing.