Skip to content

Password

Argon2id hashing with a random salt and burn_verify on the miss path — timing never distinguishes a wrong password from an absent account.

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 at a pinned work factor — Argon2id, m = 19 MiB, t = 2, p = 1, the OWASP Password Storage Cheat Sheet’s recommendation — rather than riding the crate’s defaults, which an upstream release could lower without a line changing here.

crates/features/src/users/service.rs
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 draws a fresh 16-byte salt from the system CSPRNG on every call — the PHC specification’s recommended length — so you never reuse a salt and never store one separately. The returned string encodes the PHC-format hash, salt, and parameters in one self-describing column, and verify_password reads the parameters back out of it: moving the pinned work factor never invalidates a credential already stored.

crates/features/src/users/service.rs (from the demo)
use nest_rs::authn::hash_password;
use nest_rs::seaorm::{Repo, ServiceError};
pub async fn register_with_password(
&self,
input: CreateUser,
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 (from the demo)
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: "features::users", identity = %redact_email(email), reason = "unknown_email", "login failed");
return Err(CredentialError);
};
let Some(ref hash) = user.password_hash else {
burn_verify(password);
tracing::warn!(target: "features::users", identity = %redact_email(email), reason = "no_password_set", "login failed");
return Err(CredentialError);
};
match verify_password(hash, password) {
Ok(true) => Ok(user),
Ok(false) => {
tracing::warn!(target: "features::users", identity = %redact_email(email), reason = "bad_password", "login failed");
Err(CredentialError)
}
Err(e) => {
tracing::error!(target: "features::users", identity = %redact_email(email), error = %e, reason = "unverifiable_hash", "login failed");
Err(CredentialError)
}
}
}

Four things in that snippet are the rule rather than the style, and each was a defect before it was a habit:

  • verify_password is matched, never unwrap_or(false). A stored hash that cannot be parsed is not a wrong password — folding the two loses the error event and reports a broken record as an ordinary miss.
  • The target is features::users, not nest_rs::authn. A span target names the crate that emitted the event; app code writing a framework target makes the event unattributable.
  • The email is redacted. It is a warn+ security event, so the raw address would be PII in exactly the log an incident query reads.
  • Every line carries a reason. Three identical "login failed" warns tell an operator nothing about which path fired.

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:

nest_rs::authn::CredentialError
#[derive(Debug, Clone, thiserror::Error)]
#[error("invalid credentials")]
pub struct CredentialError;

A login endpoint turns it into 401. A token endpoint serving the password grant wraps it in TokenError::InvalidGrant instead — RFC 6749 §5.2 assigns rejected resource-owner credentials to invalid_grant (400) and reserves invalid_client (401) for a failure of the client’s own authentication. The two are not interchangeable, and the status differs, so pick by which endpoint you are serving. 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.
  • Authentication — Strategy, AuthnGuard<S>, the guard that runs them.
  • Throttler — slowing brute-force attempts at the controller edge.