Password
A password-login service does three things: store a hash on signup, compare it on signin, and refuse a login without telling the attacker whether the email exists. The framework ships three small helpers that cover the cryptography and the timing-attack mitigation — everything else (lockouts, registration policy, mailers) belongs to your service.
The helpers wrap the argon2 crate with
default parameters (Argon2id, random salt, OsRng).
Three functions
Section titled “Three functions”use nest_rs_authn::{hash_password, verify_password, burn_verify};
let hash: String = hash_password("hunter2")?;let ok: bool = verify_password(&hash, "hunter2")?;burn_verify("anything"); // runs a verify against a dummy hash| Function | Returns | Use |
|---|---|---|
hash_password(&str) | Result<String, PasswordError> | Store this string on signup |
verify_password(&hash, &str) | Result<bool, PasswordError> | Compare on signin |
burn_verify(&str) | () | Run on the miss path so timing matches |
hash_password generates a fresh salt per call (SaltString::generate(&mut OsRng))
— never reuse a salt, never store one separately. The returned string
encodes the PHC-format hash, salt, and parameters in one self-describing
column.
Signup — hash then store
Section titled “Signup — hash then store”use nest_rs_authn::{ServiceError, hash_password};
pub async fn register_with_password( &self, input: CreateUserDto, org_id: Uuid, password: &str,) -> Result<entity::Model, ServiceError> { let hash = hash_password(password) .map_err(|_| ServiceError::Db(DbErr::Custom("password hashing failed".into())))?; let active = active_for_new_user(input, org_id, Some(hash)); let conn = Repo::<entity::Entity>::conn()?; Ok(active.insert(&conn).await?)}A failed hash collapses to the same ServiceError::Db the HTTP layer
already handles — the wire shape stays uniform, the cause stays in the
trace.
Signin — burn on every miss path
Section titled “Signin — burn on every miss path”The naïve signin path leaks existence: an absent email returns in microseconds, a present one takes ~50ms to hash and compare. An attacker probes the timing and enumerates the user base.
burn_verify runs a real Argon2 verify against a static dummy hash so
every miss path takes the same wall-clock time as a real verify:
use nest_rs_authn::{CredentialError, burn_verify, verify_password};
pub(crate) fn verify_credentials( email: &str, user: Option<entity::Model>, password: &str,) -> Result<entity::Model, CredentialError> { let Some(user) = user else { burn_verify(password); tracing::warn!(target: "nest_rs::authn", %email, "login failed"); return Err(CredentialError); };
let Some(ref hash) = user.password_hash else { burn_verify(password); tracing::warn!(target: "nest_rs::authn", %email, "login failed"); return Err(CredentialError); };
if !verify_password(hash, password).unwrap_or(false) { tracing::warn!(target: "nest_rs::authn", %email, "login failed"); return Err(CredentialError); } Ok(user)}Three miss paths, three calls — none distinguishable from the wire:
- No user with that email →
burn_verify, returnCredentialError. - User found but no
password_hash(OAuth-only account) →burn_verify, returnCredentialError. - Hash exists, password wrong →
verify_passwordalready ran (real Argon2 work), returnCredentialError.
The dummy hash is computed once and cached in a OnceLock so the
first burn pays the hash cost and every subsequent burn reuses it.
CredentialError — opaque by design
Section titled “CredentialError — opaque by design”Every failure path returns the same CredentialError, whose
Display is the fixed string "invalid credentials". Wrong email,
wrong password, no password set, DB unreachable — all the same on the
wire:
#[derive(Debug, Clone, thiserror::Error)]#[error("invalid credentials")]pub struct CredentialError;A login endpoint turns it into 401 (or wraps it in TokenError::InvalidCredentials
for the RFC 6749 wire code). The tracing::warn! calls in
verify_credentials capture the actual reason for operators — they
never reach the client.
PasswordError — hashing-level failures
Section titled “PasswordError — hashing-level failures”hash_password and verify_password return PasswordError:
| Variant | Meaning |
|---|---|
HashFailed | Argon2 refused to hash (rare — usually an OOM or RNG failure) |
InvalidHash | The stored string did not parse as a PHC hash — schema drift or corruption |
A login service collapses both into CredentialError so the wire
stays uniform; only the stored-hash-corrupt case logs distinctly.
What the helpers do not cover
Section titled “What the helpers do not cover”- Lockouts and rate-limiting. Pair signin with
ThrottlerGuardat the controller level. - Password policy. Validate the input with
validatorbefore callinghash_password. - Account-lookup queries. The service decides what to load, the helpers only hash.
- Refresh tokens, session rotation. Live in your token-issuer’s domain — the auth crate stays storage-agnostic.
Going further
Section titled “Going further”- JWT — the access token a successful login issues.
- Authentication —
Strategy,AuthGuard<S>, the guard that runs them. - Throttler — slowing brute-force attempts at the controller edge.