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.
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 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.
Signup — hash then store
Section titled “Signup — hash then store”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.
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: "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_passwordis matched, neverunwrap_or(false). A stored hash that cannot be parsed is not a wrong password — folding the two loses theerrorevent and reports a broken record as an ordinary miss.- The target is
features::users, notnest_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, 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. 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.
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,AuthnGuard<S>, the guard that runs them. - Throttler — slowing brute-force attempts at the controller edge.