Skip to content

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

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

crates/features/src/users/service.rs
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.

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:

crates/features/src/users/service.rs
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, return CredentialError.
  • User found but no password_hash (OAuth-only account) → burn_verify, return CredentialError.
  • Hash exists, password wrong → verify_password already ran (real Argon2 work), return CredentialError.

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.

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.

hash_password and verify_password return PasswordError:

VariantMeaning
HashFailedArgon2 refused to hash (rare — usually an OOM or RNG failure)
InvalidHashThe 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.

  • Lockouts and rate-limiting. Pair signin with ThrottlerGuard at the controller level.
  • Password policy. Validate the input with validator before calling hash_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.
  • JWT — the access token a successful login issues.
  • AuthenticationStrategy, AuthGuard<S>, the guard that runs them.
  • Throttler — slowing brute-force attempts at the controller edge.