Password Hashing in Rust With Argon2 Parameters That Hold Up
Hash passwords with Argon2id using the argon2 crate: choose parameters, store the PHC string, verify in constant time and rehash when settings change.

The question after a database leak is how fast an attacker can turn hashes back into passwords. With a fast hash, the answer is minutes for common passwords. A password hash is deliberately slow and memory-hungry, so each guess costs real resources. Argon2id is the choice for new code. This post shows how to hash and verify in Rust and how to choose the cost. It belongs with the authentication posts in security for Rust developers.
argon2 0.5, Rust 1.90.
Hash and verify
[dependencies]
argon2 = "0.5"use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
fn hash(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
fn verify(password: &str, stored: &str) -> bool {
let Ok(parsed) = PasswordHash::new(stored) else {
return false;
};
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
}
fn main() {
let stored = hash("correct horse battery staple").expect("hashing works");
println!("{}", &stored[..30]);
println!("{}", verify("correct horse battery staple", &stored));
println!("{}", verify("wrong", &stored));
}The program prints the start of the PHC string (something like $argon2id$v=19$m=19456,t=2,p=1$), then true, then false. Argon2::default() is Argon2id with the crate's default parameters, and the salt is 16 random bytes from the operating system. verify_password compares in constant time; do not compare hash strings yourself (constant time comparisons).
What the PHC string contains
$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>: the algorithm variant, version, memory in KiB (m), iterations (t), parallelism (p), then the salt and hash in base64. Store the whole string in one TEXT column. Verification reads the parameters from it, so old hashes keep working after you raise the settings.
Choosing parameters
use argon2::{Algorithm, Argon2, Params, Version};
let params = Params::new(46 * 1024, 1, 1, None).expect("valid params");
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);Params::new(memory_kib, iterations, parallelism, output_len). OWASP lists several equivalent profiles that trade memory against time, for example 19 MiB with 2 iterations or 46 MiB with 1 iteration, both with parallelism 1. Time one hash on production-class hardware with std::time::Instant; target 50 to 250 ms. Remember the cost is per login attempt: a server doing 100 logins per second needs the CPU and 100 times the memory at peak. That is also a denial of service risk, so rate limit login endpoints.
Blocking
Hashing is CPU work and takes tens of milliseconds. In an async service, call it from tokio::task::spawn_blocking, or it will stall the worker (blocking the Tokio runtime).
Upgrading old hashes
When parameters change, existing hashes stay valid. At a successful login, parse the stored string, compare its parameters to current ones, and if weaker, hash the password again and save it. Users who never log in keep old hashes, which is acceptable.
Limits
Hashing protects stored credentials; it does not stop credential stuffing or weak passwords. Add rate limiting, breached-password checks and multi-factor authentication where it matters. The specification is RFC 9106, and OWASP's Password Storage Cheat Sheet gives current parameter guidance.


