← All articles

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.

toml
# 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:

toml
[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. Use gen_range().
  • Confusing thread_rng() with a weak generator. It is actually a ChaCha12 CSPRNG seeded from the OS and is fine for most uses; OsRng is simply easier to audit and cannot be cloned into a state you accidentally persist.
  • unwrap() on hashing. Argon2 returns a Result for 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.