Skip to content

Password Hashing

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.

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 us Argon2::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, so verify_password always uses the exact parameters the password was originally hashed with, even if we tune Argon2’s cost parameters for new hashes later. One text column (password_hash) is all users needs.
  • 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 hunter42 get completely different password_hash values, 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.
Terminal window
cd taskflow/backend
cargo add argon2 -p api

This adds argon2 = "0.5" under [dependencies] in api/Cargo.toml.

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_password returns anyhow::Result<String>, matching every other fallible, non-HTTP function we’ve written since config-tracing; argon2::password_hash::Error doesn’t implement std::error::Error in a way ? can auto-convert, so we wrap it in anyhow::anyhow! with a short message.
  • verify_password returns a plain bool, not a Result — a malformed stored hash and a genuinely wrong password both just mean “not authenticated,” and the let ... 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 (returns false) rather than panicking.

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.

Terminal window
cargo check -p api

Then run the new unit tests:

Terminal window
cargo test -p api auth::password

Expected output:

running 2 tests
test auth::password::tests::rejects_a_malformed_hash ... ok
test auth::password::tests::hash_and_verify_round_trip ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

cargo 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.