Passwords are liabilities, not data
You're building a user registration endpoint. The form posts a password. Your instinct is to save it to the database. If you do that, you're holding a liability, not a feature. A database leak means every user's account is compromised instantly. Passwords never touch storage in plain text. You need a one-way transformation that turns "hunter2" into a long string of gibberish, and makes it computationally expensive to reverse.
Hashing is a one-way transformation
Hashing is like boiling spaghetti. You can turn raw pasta into a cooked dish, but you can't turn the cooked dish back into raw pasta. The information is lost. That's the goal. You store the hash. When a user logs in, you hash their input and compare the hashes. If they match, the password is correct. You never know the password, only that the hash matches.
Two concepts make password hashing secure. Salt is random data mixed in before hashing. Without salt, "password" always hashes to the same value. Attackers can pre-compute hashes for common passwords using rainbow tables. Salt forces them to compute a new hash for every user. Work factor controls how slow the hash is. Fast hashes like SHA-256 are bad for passwords because GPUs can guess billions per second. Password hashes like Argon2 or bcrypt are designed to be slow and memory-hard. They throttle attackers by consuming time and memory for every guess.
Hashing is a one-way street. Once the data is transformed, the original is gone forever.
Minimal example with Argon2
The argon2 crate implements Argon2id, the current industry standard. It handles salt generation, parameter encoding, and constant-time verification automatically.
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
fn main() {
// Raw password bytes from the user input.
let password = b"super_secret_password";
// Generate a cryptographically secure random salt.
// Never reuse salts across users.
let salt = SaltString::generate(&mut OsRng);
// Create the hasher with default parameters.
// Defaults are tuned for security, not speed.
let argon2 = Argon2::default();
// Hash the password. This takes noticeable time.
// The result includes the algorithm, params, salt, and hash.
let hash = argon2.hash_password(password, &salt).unwrap().to_string();
println!("Hash: {}", hash);
// Verification requires parsing the hash string back.
let parsed_hash = PasswordHash::new(&hash).unwrap();
// Check if the password matches the stored hash.
argon2.verify_password(password, &parsed_hash).unwrap();
}
The crate handles the complexity. You just get a string you can store.
What happens under the hood
The hash_password call does the heavy lifting. Argon2id allocates a large block of memory, 64MB by default, and fills it using the password and salt. It runs multiple passes over this memory. This makes the hash resistant to GPU and ASIC attacks, which excel at parallel processing but struggle with memory bandwidth.
The output string encodes the algorithm version, memory cost, time cost, parallelism, the salt, and the resulting hash. This self-describing format follows the PHC string standard. You can store the entire string in your database and verify later without managing parameters separately. The string looks like $argon2id$v=19$m=65536,t=3,p=4$....
Verification parses this string, extracts the parameters, re-runs the hash with the candidate password, and compares the result. The comparison is constant-time. It takes the same amount of time regardless of how many bytes match. This prevents timing attacks where an attacker measures response time to guess the hash byte-by-byte.
Convention note: Stick to the PHC string format. Don't try to split the salt and hash manually. The crate expects the full string for verification. Storing them separately adds complexity and risk of mismatch.
Realistic user registration
In production code, wrap the logic in a struct. Use proper error handling instead of unwrap.
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::Error as Argon2Error;
/// Represents a user with a hashed password.
struct User {
username: String,
password_hash: String,
}
impl User {
/// Creates a new user by hashing the provided password.
///
/// Returns the user with the password hash stored.
fn new(username: &str, password: &[u8]) -> Result<Self, Argon2Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2.hash_password(password, &salt)?.to_string();
Ok(User {
username: username.to_string(),
password_hash: hash,
})
}
/// Verifies a password against the stored hash.
///
/// Returns true if the password matches.
fn verify_password(&self, password: &[u8]) -> Result<bool, Argon2Error> {
let parsed_hash = PasswordHash::new(&self.password_hash)?;
let argon2 = Argon2::default();
argon2.verify_password(password, &parsed_hash).map(|_| true)
}
}
Convention note: Use Argon2::default() in production. The defaults are chosen to balance security and performance on modern hardware. Tuning parameters yourself is a common source of mistakes. If you need to adjust performance, change the memory cost, but keep it high enough to be slow. Security experts review the defaults. Changing them without a specific reason often weakens security.
Wrap the logic in a struct. Keep the hash and the user together.
Common pitfalls and errors
Never use fast hashes like SHA-256 or MD5 for passwords. These are designed for speed and integrity checking. Attackers can guess billions of passwords per second against these algorithms. Password hashing requires slowness.
Never generate your own salt with a simple random number generator. Use OsRng or the crate's salt generation. Predictable salts defeat the purpose.
If you pass a String directly to hash_password instead of bytes, the compiler rejects you with E0308 (mismatched types). The API expects &[u8]. Convert with password.as_bytes().
If you pass a malformed hash string to PasswordHash::new, it returns an error. This usually means the database value is corrupted or truncated. Check your column length. The hash string can be around 97 characters. Ensure your DB schema allows enough space.
Fast hashes are for data integrity, not passwords. Slow down on purpose.
Choosing a hashing algorithm
Use Argon2id when you are building a new system or can choose the algorithm. It is the current industry standard, winning the Password Hashing Competition. It offers protection against GPU attacks through memory hardness and side-channel resistance.
Use Bcrypt when you are maintaining legacy code or have a constraint that prevents upgrading to Argon2. Bcrypt is still secure and widely supported, but it lacks memory hardness and has a 72-byte password limit.
Use the argon2 crate when you need a safe, ergonomic interface with sensible defaults. It handles salt generation, parameter encoding, and constant-time verification automatically.
Reach for raw cryptographic primitives only when you are implementing a custom protocol that requires specific hash parameters not supported by high-level crates. This is rare for password storage.
Pick the tool that matches your threat model. Argon2 is the default choice for good reason.