Generating and hashing passwords in Rust
Rust's type system will not stop you from writing a biased random
number generator, so the interesting parts here are OsRng and
gen_range — and choosing hashing crates that expose real errors.
1. Generate a password
OsRng reads from the operating system CSPRNG, and gen_range()
handles the mapping onto your alphabet with rejection sampling — so no modulo bias. Rolling
your own index with % reintroduces exactly the bias the function avoids.
# rand 0.8 (the API below). In rand 0.9 gen_range was renamed to random_range.
[dependencies]
rand = "0.8"
rust
use rand::rngs::OsRng;
use rand::Rng;
const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
const DIGITS: &str = "0123456789";
const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.?/~";
/// OsRng is the operating system CSPRNG. gen_range() is rejection-sampled, so
/// the distribution over the alphabet is uniform even when it is not a power of two.
pub fn generate(length: usize, alphabet: &str) -> String {
assert!(length > 0, "length must be positive");
let chars: Vec<char> = alphabet.chars().collect();
assert!(!chars.is_empty(), "alphabet must not be empty");
let mut rng = OsRng;
(0..length)
.map(|_| chars[rng.gen_range(0..chars.len())])
.collect()
}
/// One character from every set, then a Fisher-Yates shuffle with the same RNG,
/// so the guaranteed characters are not always at the front.
pub fn generate_structured(length: usize) -> String {
let sets = [UPPER, LOWER, DIGITS, SYMBOLS];
assert!(length >= sets.len(), "length must cover every set");
let mut rng = OsRng;
let mut chars: Vec<char> = sets
.iter()
.map(|set| {
let set: Vec<char> = set.chars().collect();
set[rng.gen_range(0..set.len())]
})
.collect();
let pool: Vec<char> = sets.concat().chars().collect();
while chars.len() < length {
chars.push(pool[rng.gen_range(0..pool.len())]);
}
for i in (1..chars.len()).rev() {
let j = rng.gen_range(0..=i);
chars.swap(i, j);
}
chars.into_iter().collect()
}
2. Hash with Argon2id
The argon2 crate from RustCrypto produces standard PHC strings and takes its
salt from a CSPRNG:
[dependencies]
argon2 = "0.5"
password-hash = { version = "0.5", features = ["rand_core"] }
rust
use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
/// Returns a PHC string: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
pub fn hash_argon2id(password: &str) -> Result<String, argon2::password_hash::Error> {
// memory in KiB, iterations, lanes
let params = Params::new(64 * 1024, 3, 4, None)?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let salt = SaltString::generate(&mut OsRng); // 16 random bytes, encoded
Ok(argon2.hash_password(password.as_bytes(), &salt)?.to_string())
}
/// Verification: parse the stored hash, re-derive, compare in constant time
/// (verify_password does that internally).
pub fn verify_argon2id(password: &str, stored: &str) -> bool {
match PasswordHash::new(stored) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false, // malformed hash: treat as "does not match"
}
}
Note that Argon2::default() parses the parameters from the stored string, so
verification uses the same memory and iteration counts the hash was created with — no need
to keep them alongside. If you accept hashes from untrusted sources, check the parsed
parameters before verifying them, for the same reason Go code must cap the bcrypt cost:
m=4194304 in a submitted hash is a request to allocate gigabytes.
3. Hash with bcrypt
toml[dependencies]
bcrypt = "0.15"
rust
/// Cost 12 is a sensible 2026 baseline; the crate's DEFAULT_COST is 12 as well.
pub fn hash_bcrypt(password: &str) -> Result<String, bcrypt::BcryptError> {
bcrypt::hash(password, 12) // "$2b$12$..."
}
pub fn verify_bcrypt(password: &str, stored: &str) -> Result<bool, bcrypt::BcryptError> {
// Truncates the same way every bcrypt does: the first 72 bytes only.
bcrypt::verify(password, stored)
}
Pitfalls in Rust
%on a random byte.rng.next_u32() as usize % alphabet.len()is biased whenever the alphabet size does not divide 232. Usegen_range().- Confusing
thread_rng()with a weak generator. It is actually a ChaCha12 CSPRNG seeded from the OS and is fine for most uses;OsRngis simply easier to audit and cannot becloned into a state you accidentally persist. unwrap()on hashing. Argon2 returns aResultfor reasons — out-of-memory conditions and malformed stored hashes are normal in production, not impossible. Treat an error as "does not match" and log it.- Truncating the encoded hash to fit a column. A PHC string with a 32-byte key is around 90–100 characters; give it room (255 is a common, safe column width) rather than trimming it.
- Comparing hashes yourself. Both crates compare in constant time internally. Do not "optimise" by comparing strings first.
Checking output against another implementation
Because PHC strings are self-describing, a hash produced by the argon2 crate
verifies anywhere — including in the Verify tab here, which
uses Go's implementation. Cross-checking two independent implementations is a good way to
catch a mis-set memory or iteration value before it reaches production.
Verify a Rust-produced hash — paste the PHC string and the password, and compare implementations.