Session-based authentication fits naturally into a hypermedia-driven architecture. The server manages all auth state. The browser sends a cookie. No client-side token management, no JWT parsing in JavaScript, no OAuth dance in the browser. The server decides who the user is, renders the appropriate HTML, and sends it.
This section builds authentication with tower-sessions for session management, argon2 for password hashing, and tower-csrf for cross-site request forgery protection. PostgreSQL stores both user records and session data via tower-sessions-sqlx-store.
Dependencies
[dependencies]
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
tower-csrf = "0.1"
argon2 = "0.5"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "time", "uuid"] }
time = "0.3"
uuid = { version = "1", features = ["v4", "serde"] }
tower-sessions provides the session middleware layer. tower-sessions-sqlx-store backs it with PostgreSQL so sessions survive server restarts. argon2 handles password hashing using the Argon2id algorithm, the OWASP primary recommendation. tower-csrf protects state-changing requests from cross-site forgery.
Note the version pairing: tower-sessions 0.14 and tower-sessions-sqlx-store 0.15 are compatible through their shared dependency on tower-sessions-core 0.14. Check both crates for newer matching releases.
Password hashing
Argon2id is memory-hard and CPU-hard, which makes brute-force attacks expensive even with GPUs. The argon2 crate provides a pure-Rust implementation.
Passwords are stored as PHC-format strings. The algorithm, version, and parameters are embedded alongside the hash, making the value self-describing:
$argon2id$v=19$m=65536,t=2,p=1$<salt>$<hash>
This means you can change hashing parameters over time without breaking verification of existing hashes. During verification, the argon2 crate reads parameters from the stored hash, not from the Argon2 instance.
use argon2::{
password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
},
Algorithm, Argon2, Params, Version,
};
fn build_hasher() -> Argon2<'static> {
let params = Params::new(
64 * 1024, // 64 MiB memory cost
2, // 2 iterations
1, // 1 degree of parallelism
None, // default output length (32 bytes)
)
.expect("valid argon2 params");
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
}
fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let hash = build_hasher().hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
fn verify_password(
password: &str,
stored_hash: &str,
) -> Result<(), argon2::password_hash::Error> {
let parsed = PasswordHash::new(stored_hash)?;
Argon2::default().verify_password(password.as_bytes(), &parsed)
}
SaltString::generate(&mut OsRng) produces a cryptographically random salt using the OS random number generator. The build_hasher function configures Argon2id with 64 MiB of memory, which is a reasonable starting point. Argon2::default() uses 19 MiB (the OWASP floor), but the recommendation is 64 MiB or higher if your server can handle it. Tune the memory parameter upward until hashing takes roughly 200ms on your production hardware.
The verify_password function uses Argon2::default() because it reads parameters from the stored hash, not from the instance. This means old hashes created with different parameters continue to verify correctly.
Peppering
A pepper is a secret key stored only in the application server, never in the database. If the database leaks but the application server is not compromised, the pepper makes the stolen hashes unverifiable. Argon2 has a built-in secret parameter for this:
fn build_hasher_with_pepper(pepper: &[u8]) -> Argon2<'_> {
let params = Params::new(64 * 1024, 2, 1, None).expect("valid argon2 params");
Argon2::new_with_secret(pepper, Algorithm::Argon2id, Version::V0x13, params)
.expect("valid argon2 secret")
}
Generate the pepper once (32 random bytes from a CSPRNG), store it as an environment variable or in a secrets manager, and load it at application startup. If the pepper is lost, all password hashes become unverifiable and every user must reset their password. Treat it with the same care as a database encryption key.
If you want a simpler API, the password-auth crate wraps argon2 with two functions (generate_hash, verify_password) and provides is_hash_obsolete() for detecting when stored hashes should be re-hashed with newer parameters. The lower-level API shown here gives more control when you need it.
Async context
Argon2 hashing is CPU-intensive. A single hash takes 50-200ms depending on hardware. Running it directly in an async handler blocks the tokio worker thread and starves other requests. Always offload to the blocking thread pool:
use tokio::task;
async fn hash_password_async(password: String) -> Result<String, anyhow::Error> {
task::spawn_blocking(move || hash_password(&password))
.await?
.map_err(Into::into)
}
async fn verify_password_async(
password: String,
stored_hash: String,
) -> Result<(), anyhow::Error> {
task::spawn_blocking(move || verify_password(&password, &stored_hash))
.await?
.map_err(Into::into)
}
The closure takes owned String values because spawn_blocking requires 'static. This moves the work to tokio’s dedicated blocking thread pool (separate from the async worker threads), keeping the async runtime responsive.
Password validation
Enforce constraints before hashing:
- Minimum length: 10 characters. Shorter passwords are too easy to brute-force.
- Maximum length: 128 characters. Without a maximum, an attacker can submit multi-megabyte passwords to exhaust server resources through expensive hashing.
- Unicode normalisation: Apply NFKC normalisation before hashing. Different systems represent the same characters differently, which causes cross-platform login failures. The
unicode-normalizationcrate handles this.
For password quality checking, the zxcvbn crate (a Rust port of Dropbox’s password strength estimator) catches common and weak passwords without maintaining a separate banned-password list.
Session layer
Set up a PostgreSQL-backed session store, run its migration to create the session table, and start a background task to clean up expired sessions.
use axum::Router;
use sqlx::PgPool;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::PostgresStore;
use time::Duration;
async fn session_layer(pool: PgPool) -> SessionManagerLayer<PostgresStore> {
let store = PostgresStore::new(pool);
store.migrate().await.expect("session table migration failed");
// Clean up expired sessions every 60 seconds
tokio::task::spawn(
store
.clone()
.continuously_delete_expired(tokio::time::Duration::from_secs(60)),
);
SessionManagerLayer::new(store)
.with_secure(true)
.with_expiry(Expiry::OnInactivity(Duration::hours(24)))
}
PostgresStore::migrate() creates a tower_sessions schema with a session table (columns: id TEXT, data BYTEA, expiry_date TIMESTAMPTZ). The continuously_delete_expired task runs in the background, removing sessions that have passed their expiry date.
Cookie configuration
SessionManagerLayer configures the session cookie through builder methods:
with_secure(true)sets theSecureflag so the cookie is only sent over HTTPS. Always enable this in production.with_http_only(true)is the default. The cookie is inaccessible to JavaScript, protecting against XSS-based session theft.with_same_site(SameSite::Lax)is the default. Cookies are sent on top-level navigations but not on cross-site subrequests. Combined with CSRF protection, this is sufficient for most applications. UseSameSite::Strictfor high-security applications, with the trade-off that users clicking links to your site from email will appear logged out on first load.
Expiry options
tower-sessions supports three expiry strategies:
Expiry::OnInactivity(Duration)resets the expiration on each request. A sliding window. Good for most applications.Expiry::AtDateTime(OffsetDateTime)sets a fixed expiration. The session expires at that time regardless of activity.Expiry::OnSessionEndcreates a browser session cookie with noMax-Age. The cookie is deleted when the browser closes.
The default when no expiry is set is two weeks. For applications handling sensitive data, consider shorter windows (1-24 hours) and requiring re-authentication for high-risk actions.
Layer ordering
Apply the session layer as the outermost middleware so sessions are available to all inner layers and handlers:
let app = Router::new()
.route("/register", get(show_register).post(handle_register))
.route("/login", get(show_login).post(handle_login))
.route("/logout", post(handle_logout))
.layer(csrf_layer)
.layer(session_layer(pool).await);
In Axum, the last .layer() call is the outermost layer and processes requests first. Here, the session layer processes first (loads the session from the cookie), then the CSRF layer checks the request origin, then the handler runs.
User table
Create a migration for the users table:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
email_confirmed_at TIMESTAMPTZ,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The corresponding Rust struct:
use sqlx::types::time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub email_confirmed_at: Option<OffsetDateTime>,
pub password_hash: String,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}Registration
The registration handler validates input, hashes the password, and creates the user. It does not reveal whether an email is already taken, to prevent account enumeration.
use axum::{extract::State, response::IntoResponse, Form};
use maud::{html, Markup};
#[derive(serde::Deserialize)]
struct RegisterForm {
email: String,
password: String,
password_confirmation: String,
}
async fn show_register() -> Markup {
html! {
h1 { "Create an account" }
form method="post" action="/register" {
label for="email" { "Email" }
input type="email" name="email" id="email" required;
label for="password" { "Password" }
input type="password" name="password" id="password"
required minlength="10" maxlength="128"
autocomplete="new-password";
label for="password_confirmation" { "Confirm password" }
input type="password" name="password_confirmation"
id="password_confirmation" required
autocomplete="new-password";
button type="submit" { "Register" }
}
}
}
async fn handle_register(
State(state): State<AppState>,
Form(form): Form<RegisterForm>,
) -> impl IntoResponse {
if form.password != form.password_confirmation {
return show_error("Passwords do not match").into_response();
}
if form.password.len() < 10 || form.password.len() > 128 {
return show_error("Password must be 10 to 128 characters").into_response();
}
let password_hash = match hash_password_async(form.password).await {
Ok(hash) => hash,
Err(_) => return show_error("Registration failed").into_response(),
};
// ON CONFLICT DO NOTHING prevents errors on duplicate email
// without revealing whether the email already exists
let result = sqlx::query(
"INSERT INTO users (email, password_hash) \
VALUES ($1, $2) ON CONFLICT (email) DO NOTHING",
)
.bind(&form.email)
.bind(&password_hash)
.execute(&state.db)
.await;
// Always show the same message. In the background, send different emails:
// - New user: send a confirmation link
// - Existing email: send "someone tried to register with your email"
// See the Email Confirmation section below for the token flow.
html! {
h1 { "Check your email" }
p { "If this email can be used for an account, you will receive further instructions." }
}
.into_response()
}
The ON CONFLICT (email) DO NOTHING query combined with a uniform response prevents attackers from probing which emails have accounts. The autocomplete="new-password" attribute tells password managers this is a registration form.
Login
The login handler verifies the password against the stored hash, creates a session, and cycles the session ID to prevent fixation attacks.
use axum::response::Redirect;
use tower_sessions::Session;
#[derive(serde::Deserialize)]
struct LoginForm {
email: String,
password: String,
}
async fn handle_login(
session: Session,
State(state): State<AppState>,
Form(form): Form<LoginForm>,
) -> impl IntoResponse {
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE email = $1")
.bind(&form.email)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let Some(user) = user else {
// Run a dummy hash to prevent timing-based user enumeration
let _ = hash_password_async("dummy-password".to_string()).await;
return show_login_error("Invalid email or password").into_response();
};
if verify_password_async(form.password, user.password_hash.clone())
.await
.is_err()
{
return show_login_error("Invalid email or password").into_response();
}
// Prevent session fixation: generate a new session ID, preserving data
session.cycle_id().await.expect("failed to cycle session ID");
// Store user identity in the session
session
.insert("user_id", user.id)
.await
.expect("failed to insert session data");
// Validate redirect target if using a ?next= parameter.
// Only allow relative paths. Reject absolute URLs to prevent open redirects.
Redirect::to("/").into_response()
}
Three security details matter here:
Timing attack prevention. When no user is found, a dummy hash_password_async call runs so the response time is similar regardless of whether the email exists. Without this, an attacker can distinguish “email not found” from “wrong password” by measuring response latency.
Session fixation prevention. session.cycle_id() generates a new session ID while preserving session data. Without this, an attacker who planted a known session ID (via a crafted link or subdomain cookie injection) could hijack the authenticated session.
Post-login redirect validation. If you add a ?next= parameter so users return to the page they were visiting before login, validate the target strictly. Allow only relative paths. Reject absolute URLs, URLs with different schemes or hosts, and URLs with embedded credentials. Without validation, an attacker can craft https://yoursite.com/login?next=https://evil.com, and the user sees a legitimate login page that redirects to a phishing site after authentication.
The error message is the same for both “user not found” and “wrong password”. Never reveal which one failed.
Rate limiting
Without rate limiting, the login endpoint is vulnerable to brute-force and credential stuffing attacks. Apply limits at two levels:
- Per-account: Lock the account after a threshold of failed attempts (for example, 10). Unlock after a cooldown period (15 minutes) or via email. This stops targeted attacks against a single user.
- Per-IP: Apply a sliding window limit (for example, 20 attempts per minute per IP). Return HTTP 429 with a
Retry-Afterheader. This slows distributed scanning.
Per-account limiting is the primary defence. Per-IP limiting alone is insufficient because botnets rotate IP addresses.
For Axum, tower_governor provides a Tower-compatible rate limiting layer based on the governor crate. Apply it to your auth routes:
use tower_governor::{GovernorConfig, GovernorLayer};
let governor_config = GovernorConfig::default(); // 1 request per 500ms per IP
let governor_layer = GovernorLayer {
config: governor_config,
};
let auth_routes = Router::new()
.route("/login", get(show_login).post(handle_login))
.route("/register", get(show_register).post(handle_register))
.layer(governor_layer);
This handles per-IP limiting. For per-account lockout, track failed attempts in a database column or a Redis counter keyed by email, and check it before verifying the password.
Logout
Destroy the session and redirect. Protect logout with a POST request, not GET, so cross-site <img> tags or link prefetching cannot force a logout.
async fn handle_logout(session: Session) -> impl IntoResponse {
session.flush().await.expect("failed to flush session");
Redirect::to("/login")
}
session.flush() clears all session data, deletes the record from the database, and nullifies the session cookie.
Extracting the current user
Build an Axum extractor that loads the authenticated user from the session. Use this wherever a handler needs the current user.
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
pub struct AuthUser(pub User);
impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let user_id: Uuid = session
.get("user_id")
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
let pool = parts
.extensions
.get::<PgPool>()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user))
}
}
Handlers that need authentication add AuthUser as a parameter. If no valid session exists, the request returns 401 before the handler body runs:
async fn dashboard(AuthUser(user): AuthUser) -> Markup {
html! {
h1 { "Welcome, " (user.email) }
}
}
For the extractor to access the database pool, add it to request extensions via middleware, or make the extractor generic over your AppState. The approach depends on how you structure shared state; see Web Server with Axum.
CSRF protection
Cross-site request forgery tricks a logged-in user’s browser into making unintended requests to your application. Traditional defences embed hidden tokens in forms. A simpler approach validates the request origin using headers the browser sends automatically.
tower-csrf implements this origin-based approach, inspired by Filippo Valsorda’s analysis of CSRF and the defence built into Go 1.25’s net/http. Instead of managing tokens, it checks the Sec-Fetch-Site and Origin headers. Modern browsers (all major browsers since 2023) send Sec-Fetch-Site: same-origin for same-site requests. Cross-origin requests are blocked. Safe methods (GET, HEAD, OPTIONS) are allowed unconditionally.
use axum::{
error_handling::HandleErrorLayer,
http::StatusCode,
response::IntoResponse,
};
use tower::ServiceBuilder;
use tower_csrf::{CrossOriginProtectionLayer, ProtectionError};
let csrf_layer = ServiceBuilder::new()
.layer(HandleErrorLayer::new(
|error: Box<dyn std::error::Error + Send + Sync>| async move {
if error.downcast_ref::<ProtectionError>().is_some() {
(StatusCode::FORBIDDEN, "Cross-origin request blocked").into_response()
} else {
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
},
))
.layer(CrossOriginProtectionLayer::default());
No hidden form fields. No hx-headers configuration for htmx. Same-origin requests pass automatically because the browser attests to the origin. This is a clean fit for HDA applications where every form submission and htmx request originates from the same domain.
If you need to accept cross-origin requests from specific origins (SSO callbacks, webhooks), add them explicitly:
let csrf = CrossOriginProtectionLayer::default()
.add_trusted_origin("https://sso.example.com")
.expect("valid origin URL");
For the full argument behind origin-based CSRF validation and why token-based CSRF is unnecessary in modern browsers, read Filippo Valsorda’s analysis.
If you need to support browsers that do not send Sec-Fetch-Site headers (pre-2023), or you prefer a traditional token-based approach, axum_csrf provides a double-submit cookie pattern compatible with Axum 0.8.
Email confirmation
Confirm email addresses before activating accounts. Without confirmation, anyone can register with someone else’s email, and your application sends unwanted messages to non-users.
The flow uses a split token pattern: a 16-byte identifier for database lookup and a 16-byte verifier for constant-time comparison. Store the SHA-256 hash of the verifier, never the verifier itself. If the database leaks, attackers cannot reconstruct valid confirmation links.
Flow
- On registration, generate an identifier (16 random bytes) and a verifier (16 random bytes) using a CSPRNG (
OsRng). - Store in a
confirmationstable: identifier (indexed), SHA-256(verifier), user ID, expiration (24-48 hours), and action type (email_confirmation). - Base64url-encode the concatenated identifier + verifier into a link:
https://example.com/confirm?token=<encoded>. - Send the link via email. See Email for sending with Lettre and testing with MailCrab.
- When the user clicks the link, require an active session (the user must be logged in). Split the token back into identifier and verifier. Look up by identifier. Check expiration. Constant-time compare SHA-256(received verifier) with the stored hash using the
subtlecrate. - On success, set
email_confirmed_aton the user record and delete the confirmation record.
Requiring an active session at step 5 prevents an attacker who intercepts the confirmation email (compromised mailbox, network interception) from confirming the account without knowing the password. The user must both possess the token and be authenticated.
Preventing enumeration
Never reveal whether an email is already registered. On registration:
- Always display: “Check your email to complete registration.”
- If the email is new, send a confirmation link.
- If the email already exists, send a different message: “Someone attempted to register with your email. If this was you, you can log in or reset your password.”
Schedule the email step asynchronously so the response time is identical in both cases. A timing difference between “new account” and “existing account” is enough for an attacker to enumerate emails.
Confirmations table
A single table handles email confirmations, password resets, and email changes:
CREATE TABLE confirmations (
identifier BYTEA PRIMARY KEY,
verifier_hash BYTEA NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
action_type TEXT NOT NULL,
details JSONB,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_confirmations_user_id ON confirmations(user_id);
The action_type column distinguishes confirmation purposes. The details column holds action-specific data as JSON (for example, the new email address during an email change).
Password reset
Password resets follow the same split token pattern as email confirmation. The key differences are a shorter expiration and the requirement to invalidate all existing sessions after a successful reset.
Flow
- User submits their email on the reset form.
- Display: “If this email belongs to an account, you will receive reset instructions.” Never reveal whether the email has an account.
- Look up the user. If found, generate a split token, store it with a 30-minute expiration and action type
password_reset, and email the link. If not found, do nothing. Schedule the work asynchronously for consistent response timing. - When the user clicks the link, verify the token (same split-and-compare as email confirmation). Show a new password form with the token as a hidden field.
- On form submission, re-verify the token, hash the new password, update the user record, delete all reset tokens for this user, invalidate all sessions for this user, create a new session, and log them in.
Security considerations
- 30-minute expiration. Reset tokens are high-value targets. Keep the window short.
- Invalidate all sessions after a successful reset. If the password was compromised, existing sessions may belong to an attacker.
- Allow multiple outstanding tokens. Don’t delete old tokens when a new reset is requested. The user may request a reset, not receive the email, and request again.
- Delete tokens on login. If the user remembers their password and logs in normally, delete their outstanding reset tokens.
- Require the new password on the same form as the token. Don’t split this into two steps. Re-verify the token on submission to prevent replay.
When to delegate authentication
Session-based auth works well for a single application with its own user base and straightforward login requirements. Delegate to an external Identity Provider when the requirements outgrow what the application should manage directly:
- Multiple applications need SSO. Users log in once and access several services. A shared identity layer is easier to maintain than per-application auth.
- Enterprise customers expect SAML or OIDC. B2B SaaS products typically need to integrate with customers’ corporate identity systems.
- Compliance frameworks require it. SOC 2, HIPAA, and PCI-DSS audits favour dedicated identity infrastructure with built-in audit logging, brute-force protection, and pre-certified MFA controls. An external IdP gives auditors a clear separation of concerns.
- Existing infrastructure. Your organisation already runs Active Directory, LDAP, or a corporate IdP that users expect to log in with.
Self-hosted identity providers
Keycloak is a full-featured open-source IdP (CNCF incubation project) supporting OAuth2, OIDC, SAML, and LDAP federation. It handles SSO, MFA, identity brokering, and user management. The trade-off is operational weight: it is a Java application with significant resource requirements.
Authentik is a lighter alternative with a more modern developer experience, supporting OAuth2, OIDC, SAML, LDAP, and SCIM.
Both align with this guide’s preference for self-hosted infrastructure.
Integration with OAuth2 Proxy
OAuth2 Proxy sits between users and your Axum application as a reverse proxy. It handles the OAuth2/OIDC flow with your IdP and forwards authenticated requests with identity headers:
use axum::http::HeaderMap;
async fn handler(headers: HeaderMap) -> Markup {
let email = headers
.get("X-Forwarded-Email")
.and_then(|v| v.to_str().ok())
.unwrap_or("anonymous");
html! { p { "Logged in as " (email) } }
}
The application reads identity from trusted headers (X-Forwarded-User, X-Forwarded-Email) without implementing OAuth2 flows directly. The proxy strips any client-supplied identity headers before injecting authenticated values, preventing spoofing.
Your application must only be reachable through the proxy, never directly from the internet. Enforce this at the network level: firewall rules, container networking, or Tailscale ACLs. If a client can bypass the proxy, it can set X-Forwarded-User to any value.
Choosing an auth strategy
| Situation | Approach |
|---|---|
| Single app, simple login | Session auth (tower-sessions + argon2) |
| Single app, social login (GitHub, Google) | oauth2 / openidconnect crate in Axum |
| Multiple apps needing SSO | External IdP (Keycloak/Authentik) + OAuth2 Proxy |
| B2B SaaS, enterprise customers | External IdP or managed service (Auth0, WorkOS) |
| SOC 2 / HIPAA / PCI-DSS compliance | External IdP strongly recommended |
| Existing Active Directory / LDAP | Keycloak |
Start with session-based auth. Move to an external IdP when you hit one of the triggers above. The migration is additive: OAuth2 Proxy sits in front of your existing application, and the AuthUser extractor reads from proxy headers instead of session data.
Implementation resources
For AI coding agents implementing authentication: the secure-auth skill provides detailed security reference material covering cryptographic fundamentals, password hashing parameters, the split token pattern, session management, MFA (TOTP, WebAuthn, recovery codes), and security review checklists. Use it as context when building the patterns described in this section.
For access to the secure-auth skill and detailed implementation guidance, contact the author.
Gotchas
spawn_blocking for all password operations. Forgetting to offload argon2 to the blocking thread pool is the most common mistake. Under load, a single blocked tokio worker thread cascades into request timeouts across the application.
Session ID cycling must happen before inserting user data. Call session.cycle_id() before session.insert("user_id", ...). If you insert first and the cycle fails, the old (potentially attacker-controlled) session ID now has authenticated data.
tower-sessions version compatibility. The tower-sessions and tower-sessions-sqlx-store crates track tower-sessions-core versions independently. If Cargo reports a version conflict on tower-sessions-core, check that the published sqlx-store version matches your tower-sessions version. Pin both until they align.
Consistent error messages on auth forms. Every registration, login, and reset form must give the same response regardless of whether the email exists. This includes response timing. An async email-sending step after registration or reset prevents timing leaks.
CSRF on logout. Logout must be a POST request protected by CSRF, not a GET link. A GET-based logout allows any cross-site <img> tag to force a logout, which is a nuisance attack that can also be chained with session fixation.