Password Hashing
What we’re building
Section titled “What we’re building”password.rs, with two functions: hash_password(plain: &str) -> anyhow::Result<String>, which turns a plaintext password into an Argon2 hash string safe to store in the users.password_hash column, and verify_password(plain: &str, hash: &str) -> bool, which checks a login attempt’s plaintext password against that stored hash without ever needing to reverse it.
Every other piece of Module 4 builds on these two functions: register calls hash_password before the INSERT, and login calls verify_password against the row it just fetched. Neither function talks to the database, Redis, or Axum at all — they’re pure, easily testable building blocks.
A password is the one secret in the whole system a user picks themselves, reuses across other services, and never expects a breach of your database to expose. If users.password_hash ever leaks — a backup left in the wrong S3 bucket, a compromised replica, an SQL injection — the only acceptable outcome is that the leaked data is useless to whoever took it. That’s what a password hashing function guarantees and plaintext storage or reversible encryption cannot.
Hashing, one-way, is different from encryption. Encryption is reversible by design — anyone holding the key can recover the plaintext, which means the key becomes a single point of failure and a single juicy target. Hashing throws the plaintext away entirely; hash_password produces a fixed-size string there is no decrypt for. Verifying a login means hashing the attempt the same way and comparing — never decrypting the stored value.
A generic hash (SHA-256, MD5) is still wrong for passwords, even though it’s one-way. General-purpose hash functions are built to be fast — that’s exactly the property you want for checksums and exactly the property you don’t want for passwords, because it’s also what makes brute-forcing every possible password fast for an attacker with a GPU. Argon2 is a password hashing function: deliberately slow and memory-hard, tuned so that hashing one password takes a fraction of a second for your server but makes hashing billions of guesses prohibitively expensive for an attacker.
Pros & cons
Section titled “Pros & cons”Argon2 (what we’re using) vs. bcrypt
- Pros: Argon2 is memory-hard — it costs a configurable amount of RAM per hash, not just CPU time, which specifically defeats GPU and ASIC cracking rigs that get their speed advantage from massive parallelism over cheap memory. It’s also the winner of the 2015 Password Hashing Competition and the algorithm actively recommended by OWASP for new systems.
argon2 = "0.5"gives usArgon2::default()— Argon2id, OWASP’s recommended parameters — with zero manual tuning. - Cons: bcrypt has a longer track record in production (in wide use since 1999) and a simpler, harder-to-misconfigure API in some ecosystems. bcrypt also silently truncates inputs over 72 bytes, which Argon2 does not — one less footgun, but not enough to give up Argon2’s memory-hardness for a new system in 2026.
PHC string format — the hash carries its own parameters (what we’re using) vs. storing salt/params in separate columns
- Pros:
Argon2::default().hash_password(...)returns a self-describing string like$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>— the algorithm, version, memory/time/parallelism cost, salt, and hash all live in that one string.PasswordHash::new(hash)parses it back out, soverify_passwordalways uses the exact parameters the password was originally hashed with, even if we tuneArgon2’s cost parameters for new hashes later. Onetextcolumn (password_hash) is allusersneeds. - Cons: if we ever want to query “which users are hashed with outdated parameters” for a rehash migration, that means parsing the PHC string per row instead of a plain SQL
WHERE cost_param < N. A reasonable trade for a course-sized project; a large-scale system might duly extract cost parameters into their own column.
A random salt per password (what we’re using, via SaltString::generate) vs. no salt / a shared salt
- Pros: two users with the identical password
hunter42get completely differentpassword_hashvalues, because each hash embeds its own random salt. This defeats rainbow tables (precomputed hash lookups) and stops an attacker who cracks one user’s password from getting a free hit on every other user who reused it. - Cons: none worth listing — an unsalted or shared-salt hash is a straightforward vulnerability, not a trade-off.
SaltString::generate(&mut OsRng)costs nothing extra to call correctly.
Build it
Section titled “Build it”1. Add the argon2 dependency
Section titled “1. Add the argon2 dependency”cd taskflow/backendcargo add argon2 -p apiThis adds argon2 = "0.5" under [dependencies] in api/Cargo.toml.
2. password.rs
Section titled “2. password.rs”Create taskflow/backend/api/src/auth/password.rs:
use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2,};
pub fn hash_password(plain: &str) -> anyhow::Result<String> { let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default() .hash_password(plain.as_bytes(), &salt) .map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))? .to_string();
Ok(hash)}
pub fn verify_password(plain: &str, hash: &str) -> bool { let Ok(parsed_hash) = PasswordHash::new(hash) else { return false; };
Argon2::default() .verify_password(plain.as_bytes(), &parsed_hash) .is_ok()}
#[cfg(test)]mod tests { use super::*;
#[test] fn hash_and_verify_round_trip() { let hash = hash_password("correct horse battery staple").unwrap(); assert!(verify_password("correct horse battery staple", &hash)); assert!(!verify_password("wrong password", &hash)); }
#[test] fn rejects_a_malformed_hash() { assert!(!verify_password("anything", "not-a-phc-string")); }}A few details worth calling out:
SaltString::generate(&mut OsRng)uses the operating system’s cryptographically secure random number generator — never a predictable or fixed salt.hash_passwordreturnsanyhow::Result<String>, matching every other fallible, non-HTTP function we’ve written since config-tracing;argon2::password_hash::Errordoesn’t implementstd::error::Errorin a way?can auto-convert, so we wrap it inanyhow::anyhow!with a short message.verify_passwordreturns a plainbool, not aResult— a malformed stored hash and a genuinely wrong password both just mean “not authenticated,” and thelet ... else { return false }pattern makes that explicit instead of forcing every call site to handle a parse error separately.- The
#[cfg(test)]module confirms the round trip works and that garbage input fails closed (returnsfalse) rather than panicking.
3. Start the auth module
Section titled “3. Start the auth module”Create taskflow/backend/api/src/auth/mod.rs:
pub mod password;Add mod auth; to taskflow/backend/api/src/main.rs:
mod auth;mod config;mod db;mod error;mod state;auth is a directory module — auth/mod.rs is its entry point, and every lesson in this module adds one more pub mod ...; line to it as we build out jwt.rs, middleware.rs, and handlers.rs.
Verify
Section titled “Verify”cargo check -p apiThen run the new unit tests:
cargo test -p api auth::passwordExpected output:
running 2 teststest auth::password::tests::rejects_a_malformed_hash ... oktest auth::password::tests::hash_and_verify_round_trip ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outcargo check -p api compiles with a handful of dead_code/never constructed warnings for now — password.rs isn’t called from any handler until handlers wires up register and login. That’s expected at this stage, same as every earlier scaffolding module.
You built hash_password and verify_password in password.rs, backed by Argon2id with a random salt per password via SaltString::generate. The PHC string format means password_hash is a single text column that carries its own algorithm parameters, so verify_password always checks against the exact settings a hash was created with. You also started the auth directory module — auth/mod.rs — that the rest of Module 4 builds on. Next, we issue and verify signed session tokens in jwt.