Deep dive
How it works
In: password, 16-byte random salt, parameters m (memory), t (passes), p (lanes) · Out: 32-byte hash, stored as a PHC string · State: none in the function; the caller stores the string · Cost: O(t·m) time, m KiB of memory; order of 10-100 ms at 19-64 MiB
- H0 = BLAKE2b(parameters, password, salt, secret, associated data).
- Fill m KiB of memory as 1 KiB blocks, split into p lanes. Each block = compression G(previous block, a reference block).
- Reference index: Argon2i picks it independently of the password (side-channel safe); Argon2d picks it from data (GPU-resistant). Argon2id uses i for the first half of pass 1, d afterwards.
- Repeat t passes over memory; final block of each lane XORed, then hashed to the output length.
Storage format (PHC string):
$argon2id$v=19$m=19456,t=2,p=1$<salt b64>$<hash b64>
Code
Rust, argon2 = "0.5" (RustCrypto).
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Algorithm, Argon2, Params, Version,
};
fn hasher() -> Argon2<'static> {
// m in KiB, t passes, p lanes, 32-byte output
let params = Params::new(19 * 1024, 2, 1, Some(32)).expect("valid params");
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
}
pub fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng); // 16 random bytes
hasher()
.hash_password(password.as_bytes(), &salt)
.expect("hashing failed")
.to_string() // PHC string: store this whole thing
}
pub enum Verify {
Ok { rehash: Option<String> },
Bad,
}
pub fn verify_password(password: &str, stored: &str) -> Verify {
let Ok(parsed) = PasswordHash::new(stored) else { return Verify::Bad };
// verify uses the params embedded in `stored`, so old hashes keep working
if hasher().verify_password(password.as_bytes(), &parsed).is_err() {
return Verify::Bad;
}
// upgrade on successful login if our current params differ
let cur = hasher();
let cur = cur.params();
let stale = parsed.algorithm.as_str() != "argon2id"
|| Params::try_from(&parsed)
.map(|p| (p.m_cost(), p.t_cost(), p.p_cost()) != (cur.m_cost(), cur.t_cost(), cur.p_cost()))
.unwrap_or(true);
Verify::Ok { rehash: stale.then(|| hash_password(password)) }
}
Run the hash on a blocking thread pool (tokio::task::spawn_blocking), not on an async executor thread.
Complexity and constants
- Cost per hash ≈ t × m memory traffic. m = 19 MiB, t = 2 is order of 10-50 ms on a modern core; m = 64 MiB, t = 3 order of 100-200 ms.
- Server capacity: one core does order of 20-100 logins/s at the OWASP minimum. Memory: m × concurrent logins (100 parallel × 64 MiB = 6.4 GiB). Cap concurrency with a semaphore.
- Equivalent OWASP alternatives when memory is scarce: m = 12 MiB/t = 3, m = 9 MiB/t = 4, m = 7 MiB/t = 5 (all p = 1).
- Fallbacks: scrypt N = 2^17, r = 8, p = 1 (128 MiB); bcrypt cost ≥ 10; PBKDF2-HMAC-SHA256 600,000 iterations (only when FIPS forces it).
Efficiency
- Cost: deliberate: m = 19 MiB, t = 2 is order of 10-50 ms and 19 MiB per hash; m = 64 MiB, t = 3 order of 100-200 ms. Bound: memory bandwidth and latency (about t × m bytes touched); the goal is least defender CPU and RAM per unit of attacker cost.
- Levers:
- Memory over passes — OWASP equivalents 19 MiB/t=2 … 7 MiB/t=5 — take the largest m your peak login concurrency affords.
- Lanes — p = 1 maximizes throughput under many logins; p > 1 cuts one hash's latency using cores (
parallelfeature of theargon2crate) — few concurrent logins. - Semaphore plus blocking pool — caps RAM at m × running hashes and blunts login floods — every server.
- Hash only when needed — re-hash on parameter change, reject oversized input, one SHA-256 for random tokens — always.
- Naive → standard → state of the art:
- Naive · salted SHA-256 · fast to crack, almost free to compute
- Standard · Argon2id at OWASP parameters, order of 10-50 ms
- State of the art · passkeys: no stored secret to hash; bcrypt cost ≥ 10 only where memory is scarce
Implementation notes
- Tune: pick the largest m you can afford for peak login concurrency, then raise t until you hit the latency budget.
- Pepper (optional): a server-side secret kept outside the DB (
Argon2::new_with_secret, or HMAC the password first). Adds protection if only the DB leaks; you can't rotate it without re-hashing on login. - Normalize Unicode passwords (NFKC) before hashing so the same password typed on two keyboards matches.
- Max password length: allow at least 64 characters, cap around 1-4 KiB to stop DoS via huge inputs.
- Rate-limit login per account and per IP; hashing cost alone does not stop online guessing.
- Check new passwords against known-breached lists (k-anonymity range API of Have I Been Pwned) instead of composition rules (NIST SP 800-63B).
Pitfalls
- Using SHA-256/MD5 with a salt "because it's salted": salts stop rainbow tables, not GPU brute force.
- bcrypt pre-hashing with raw binary SHA output: NUL bytes truncate the input. Base64-encode the pre-hash if you must.
- Hashing on the async event loop: login spikes stall every request.
- Tuning on a dev laptop, then deploying to a 1-vCPU container where each hash takes 5x longer.
- Leaking account existence: return the same error and similar timing for unknown users (hash a dummy password).
- Logging request bodies that contain passwords.
Variants and alternatives
In production
- Web frameworks: Django (Argon2 option, PBKDF2 default; docs), Spring Security
Argon2PasswordEncoder, PHPpassword_hash(PASSWORD_ARGON2ID). - Password managers: Bitwarden (Argon2id KDF option; help).
- Libraries: libsodium
crypto_pwhash(Argon2id default).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| argon2 | Rust | RustCrypto/password-hashes | cargo add argon2 | — | — | — | — | pure Rust, Argon2d/i/id, PHC strings, optional parallel |
| rust-argon2 | Rust | sru-systems/rust-argon2 | cargo add rust-argon2 | — | — | — | — | older standalone port, encoded-hash helpers |
| libsodium | C | jedisct1/libsodium | brew install libsodium | — | — | — | — | crypto_pwhash (Argon2id default); bindings for most languages |
| argon2-cffi | Python | hynek/argon2-cffi | pip install argon2-cffi | — | — | — | — | bundles the C reference; check_needs_rehash() |
| golang.org/x/crypto/argon2 | Go | golang/crypto | go get golang.org/x/crypto/argon2 | — | — | — | — | IDKey; derives raw bytes, you encode the PHC string |
Sources
- Biryukov, Dinu, Khovratovich, Josefsson, RFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications, 2021. RFC
- Biryukov, Dinu, Khovratovich, Argon2: New Generation of Memory-Hard Functions for Password Hashing and Other Applications, IEEE EuroS&P 2016. paper · code
- OWASP, Password Storage Cheat Sheet. doc
- NIST, SP 800-63B: Digital Identity Guidelines, Authentication and Lifecycle Management. spec