Password Authentication
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against argon2 0.6.0 and axum-login 0.18.0 (on tower-sessions 0.14) with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf members sign in to the web UI with a username and password; the session that follows is covered on Sessions & Cookies. Passwordless and second-factor methods are discussed on this site’s Authentication Methods page, and "Sign in with GitHub" on OAuth2 & OpenID Connect.
Where it runs in the request
Password authentication touches three points of the request lifecycle (tokio, hyper, tower layers, router, extractors, handler, response):
-
Tower layers, before routing.
AuthManagerLayer(from axum-login) bundles tower-sessions' cookie and session managers (theSessionManagerLayerpassed to the builder) with its own auth service. On the way in it reads the session and, when the session holds a user id, calls the backend’sget_userand checks thesession_auth_hash; on the way out the session manager saves a modified session and sets the cookie. Layers added outside it (tracing, rate limiting) see every login attempt, including ones that fail before reaching a handler. -
Route layer, after routing.
login_required!is applied withroute_layer, so it only runs for matched routes and redirects anonymous requests to the login page before any extractor or handler of the protected route runs. -
Extractors and handler.
AuthSessionis aFromRequestPartsextractor reading what the layer put in the request extensions; theFormbody extractor runs last. The Argon2 work happens inside the handler (throughauthenticate) on the blocking thread pool, so the async worker threads that drive every other connection keep running.
Hashing with Argon2id
Passwords are never stored, only a slow, salted hash. The OWASP Password Storage cheat sheet recommends Argon2id
first (with at least 19 MiB of memory, 2 iterations and 1 lane), then scrypt, then bcrypt. The argon2 crate’s
defaults are exactly those parameters. Its output is a PHC string that records the algorithm, version, parameters
and salt next to the hash, so verification needs nothing else:
$argon2id$v=19$m=19456,t=2,p=1$k3Vw5Z0YbqJkF1x0yGvXQg$9mT0kZ4o3f...
Source: PHC string format specification
use argon2::{
Argon2, Params,
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
};
/// Hashes a password into a PHC string with a fresh random salt.
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
Ok(Argon2::default().hash_password(password.as_bytes())?.to_string())
}
/// Verifies a password; also reports whether the stored hash uses outdated parameters.
pub fn verify_password(password: &str, phc: &str) -> (bool, bool) {
let Ok(parsed) = PasswordHash::new(phc) else { return (false, false) };
let ok = Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok();
let current = Params::default();
let outdated = Params::try_from(&parsed).map_or(true, |p| {
(p.m_cost(), p.t_cost(), p.p_cost()) != (current.m_cost(), current.t_cost(), current.p_cost())
});
(ok, outdated)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let phc = hash_password("correct horse battery staple")?;
println!("{phc}");
println!("{:?}", verify_password("correct horse battery staple", &phc)); // (true, false)
println!("{:?}", verify_password("Tr0ub4dor&3", &phc)); // (false, false)
Ok(())
}
Source: argon2 0.6.0
-
hash_password(with the defaultgetrandomfeature) generates a random 16-byte salt for every hash; two users with the same password get different hashes. -
Verification reads the parameters from the stored string, so raising the cost later does not break old hashes. When a login succeeds and
outdatedis true, hash the password again with the new parameters and store it — rehash on login is the only time the plain-text password is available. -
Argon2::new_with_secretadds a pepper: a server-side secret mixed into every hash and kept outside the database (in a secret manager), so a database dump alone is not enough to crack hashes. -
argon2 0.6 (August 2026) moved to the 2024 edition (MSRV 1.85), renamed the
simplefeature topassword-hash, andhash_passwordnow takes the password and generates the salt itself (0.5 took aSaltStringargument).
Never hash on the async executor
Argon2 is deliberately expensive: with the defaults it takes tens of milliseconds of pure CPU and 19 MiB of memory.
Running it directly in an async handler blocks a tokio worker thread for that time, stalling every other request
scheduled on it. Run it on the blocking pool with tokio::task::spawn_blocking:
use argon2::{Argon2, password_hash::PasswordHasher};
async fn hash_off_thread(password: String) -> Result<String, &'static str> {
tokio::task::spawn_blocking(move || {
Argon2::default().hash_password(password.as_bytes()).map(|h| h.to_string()).map_err(|_| "hashing failed")
})
.await
.map_err(|_| "hashing task panicked")?
}
#[tokio::main]
async fn main() {
let started = std::time::Instant::now();
let phc = hash_off_thread("correct horse battery staple".to_owned()).await.expect("hash");
println!("{} chars in {:?}", phc.len(), started.elapsed());
}
Source: tokio::task::spawn_blocking
Login endpoints are an obvious denial-of-service target for the same reason: rate-limit them per IP and per account
(see Rate Limiting & Load Shedding) and consider a
tokio::sync::Semaphore capping concurrent hashes.
Registration, login and logout with axum-login
axum-login builds on tower-sessions. You implement AuthnBackend — how to authenticate credentials and how to load
a user by id — and it provides the AuthSession extractor with authenticate, login (which also cycles the session
id) and logout, plus the login_required! route layer. The user’s session_auth_hash (here, the password hash) is
stored in the session, so changing the password invalidates all existing sessions.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use argon2::{
Argon2,
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
};
use axum::{
Form, Router,
http::StatusCode,
response::{IntoResponse, Redirect},
routing::{get, post},
};
use axum_login::{AuthManagerLayerBuilder, AuthSession, AuthUser, AuthnBackend, UserId, login_required};
use serde::Deserialize;
use tower_sessions::{MemoryStore, SessionManagerLayer};
// ---------- user and backend ----------
#[derive(Clone)]
pub struct User {
id: i64,
username: String,
password_hash: String, // never serialised, never returned
}
impl std::fmt::Debug for User {
// a derived Debug would print the hash into logs
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("User").field("id", &self.id).field("username", &self.username).finish_non_exhaustive()
}
}
impl AuthUser for User {
type Id = i64;
fn id(&self) -> i64 {
self.id
}
fn session_auth_hash(&self) -> &[u8] {
self.password_hash.as_bytes()
}
}
#[derive(Clone, Deserialize)]
pub struct Credentials {
username: String,
password: String,
}
#[derive(Clone, Default)]
pub struct Backend {
users: Arc<RwLock<HashMap<String, User>>>, // a `users` table in the real application
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("hashing task failed")]
Task,
}
/// A valid hash of a random password, verified against when the user does not exist,
/// so that unknown and known usernames take the same time.
static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
Argon2::default().hash_password(b"dummy password for timing").expect("hash").to_string()
});
impl AuthnBackend for Backend {
type User = User;
type Credentials = Credentials;
type Error = AuthError;
async fn authenticate(&self, creds: Credentials) -> Result<Option<User>, AuthError> {
let user = self.users.read().expect("lock").get(&creds.username).cloned();
let stored = user.as_ref().map_or_else(|| DUMMY_HASH.clone(), |u| u.password_hash.clone());
let valid = tokio::task::spawn_blocking(move || {
PasswordHash::new(&stored)
.is_ok_and(|parsed| Argon2::default().verify_password(creds.password.as_bytes(), &parsed).is_ok())
})
.await
.map_err(|_| AuthError::Task)?;
Ok(user.filter(|_| valid))
}
async fn get_user(&self, id: &UserId<Self>) -> Result<Option<User>, AuthError> {
Ok(self.users.read().expect("lock").values().find(|u| u.id == *id).cloned())
}
}
type Auth = AuthSession<Backend>;
// ---------- out-of-band notifications (stubs) ----------
/// Stub: queue a "someone tried to register with your address" email with a password-reset link
/// (an outbox table or a job queue in production). Never tell the HTTP client.
async fn notify_existing_account(username: String) {
let _ = username;
tracing::debug!("registration attempt for an existing account; owner notified by email");
}
/// Stub: queue the welcome/confirmation email for a new account.
async fn send_welcome(username: String) {
let _ = username;
tracing::debug!("new account; confirmation email queued");
}
/// The same answer whether or not the address was already registered.
const REGISTERED: &str = "check your email to finish signing up\n";
// ---------- handlers ----------
async fn register(auth: Auth, Form(creds): Form<Credentials>) -> Result<(StatusCode, &'static str), StatusCode> {
if creds.password.chars().count() < 12 {
return Err(StatusCode::UNPROCESSABLE_ENTITY); // OWASP: length over complexity rules
}
let password = creds.password.clone();
let hash = tokio::task::spawn_blocking(move || {
Argon2::default().hash_password(password.as_bytes()).map(|h| h.to_string())
})
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// the hash above is computed in both cases, so both branches take the same time
let already_registered = {
let mut users = auth.backend.users.write().expect("lock");
if users.contains_key(&creds.username) {
true
} else {
let id = users.len() as i64 + 1;
users.insert(creds.username.clone(), User { id, username: creds.username.clone(), password_hash: hash });
false
}
};
// the difference only reaches the mailbox, never the response (no 409 Conflict)
if already_registered {
tokio::spawn(notify_existing_account(creds.username));
} else {
tokio::spawn(send_welcome(creds.username));
}
Ok((StatusCode::ACCEPTED, REGISTERED))
}
async fn login(mut auth: Auth, Form(creds): Form<Credentials>) -> impl IntoResponse {
match auth.authenticate(creds).await {
Ok(Some(user)) => match auth.login(&user).await {
Ok(()) => Redirect::to("/account").into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
// one message for "no such user" and "wrong password"
Ok(None) => (StatusCode::UNAUTHORIZED, "invalid username or password\n").into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn logout(mut auth: Auth) -> impl IntoResponse {
match auth.logout().await {
Ok(_) => Redirect::to("/").into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn account(auth: Auth) -> String {
let user = auth.user.expect("login_required! guarantees a user");
format!("signed in as {}\n", user.username)
}
#[tokio::main]
async fn main() {
let sessions = SessionManagerLayer::new(MemoryStore::default()).with_secure(false); // dev only
let auth_layer = AuthManagerLayerBuilder::new(Backend::default(), sessions).build();
let app = Router::new()
.route("/account", get(account))
.route_layer(login_required!(Backend, login_url = "/login"))
.route("/register", post(register))
.route("/login", post(login))
.route("/logout", post(logout))
.layer(auth_layer);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum-login 0.18 and axum-login examples
$ curl -s -w '%{http_code}\n' -d 'username=ursula@bookshelf.example&password=correct horse battery staple' localhost:3000/register
check your email to finish signing up
202
$ curl -s -w '%{http_code}\n' -d "username=ursula@bookshelf.example&password=$(openssl rand -hex 12)" localhost:3000/register
check your email to finish signing up
202
$ curl -s -c jar -o /dev/null -w '%{http_code}\n' -d 'username=ursula@bookshelf.example&password=correct horse battery staple' localhost:3000/login
303
$ curl -s -b jar localhost:3000/account
signed in as ursula@bookshelf.example
$ curl -s -d 'username=nobody&password=whatever-password' localhost:3000/login
invalid username or password
$ curl -si localhost:3000/account | grep -i location
location: /login?next=%2Faccount
User enumeration and timing
An attacker should not learn whether an account exists:
-
return the same response for "unknown user" and "wrong password" (above);
-
spend the same time on both: verify against a dummy hash when the user does not exist, as
authenticatedoes — otherwise unknown usernames answer in microseconds and known ones in tens of milliseconds; -
registration and password-reset endpoints leak existence too ("this email is already registered", or a
409 Conflict). Theregisterhandler above answers202 Acceptedwith the same body for a new and an existing address, hashes the password in both cases so the timing matches, and sends the difference out of band: a confirmation email for a new account, a "someone tried to register with your address" notice with a password-reset link for an existing one (thenotify_existing_accountstub). The emails are queued on a spawned task, so their latency does not show in the response time either. In production the new account stays inactive until its confirmation link is clicked; otherwise an attacker could register and then probe with a login; -
hash comparison itself is constant-time inside
verify_password; never compare hashes or tokens with==on your own.
Never store or return plain-text passwords
The password exists in plain text only in the request body and, briefly, in memory. It must not be stored, logged
(TraceLayer does not log bodies; your Debug impls must not print it — note the hand-written Debug above), echoed
in error messages, or returned in any response. Separate output types without a password field make the mistake
impossible. Require TLS everywhere a password travels (see TLS & HTTPS).
Where the books are outdated
Yoon (Beginning Axum, chapter 3) stores users' passwords in plain text and returns them in the JSON response of the
user endpoints; chapter 4 then hashes with bcrypt inside the async handler. Both are wrong: store Argon2id hashes,
never return them, and run the hash in spawn_blocking. bcrypt is still acceptable for legacy systems, but limits
passwords to 72 bytes and is OWASP’s third choice. argon2 code written for 0.5 (SaltString::generate(&mut OsRng)
passed to hash_password) must drop the salt argument in 0.6.