Users and passwords
umbral-auth - the first built-in plugin. argon2 hashing and the canonical User model.
umbral-auth ships the canonical User model, argon2 password hashing, and
the create_user / authenticate / set_password helpers. It's the first
crate under plugins/ and the structural proof of the Plugin
contract.
Register the plugin
use umbral::prelude::*;use umbral_auth::AuthPlugin; App::builder() .settings(settings) .database("default", pool) .plugin(AuthPlugin::<AuthUser>::default()) .build()?;AuthPlugin contributes one model (AuthUser) under the "auth" plugin
namespace. Run makemigrations and migrate to create the auth_user
table.
The helpers
use umbral_auth::{authenticate, create_user, set_password}; // Create - low-level. Hashes the password and inserts the row; it does NOT// run the password-strength policy (that's the registration route's job - see// "Password validation" below).let mut alice = create_user("alice", "alice@example.com", "Tr0ub4dour&3xpl").await?; // Authenticatelet user = authenticate::<AuthUser>("alice", "Tr0ub4dour&3xpl").await?;// or: AuthError::InvalidCredentials on wrong-password OR unknown-username// (the variant is the same so callers can't enumerate accounts) // Rotate - also low-level and non-validating; a password-change form should// call `validate_password` before this, just like the register route does.set_password(&mut alice, "V10let-Sunset-quay!").await?;hash_password and verify_password are exposed too, for hand-built flows
that don't want the helpers to touch the database.
Passwords are hashed with argon2 using the crate's default parameters. The PHC-encoded hash is self-describing, so future parameter upgrades can verify old hashes and silently re-hash on next login.
Password validation
umbral ships a configurable password-strength policy and it is on by default with no opt-in. A fresh AuthPlugin::default() enforces the full set, so the built-in register route rejects weak passwords out of the box. There's no setting to flip to turn it on; it's already on.
Where it runs: the registration boundary
Validation runs at the registration route, not in the low-level creation helpers. So:
- The built-in
registerroute callsvalidate_passwordup front and returns a400(carrying every failure reason, not just the first) before any row is written. create_user,create_user_with_flags,create_superuser, andset_passwordare low-level primitives - they hash and write, but they do NOT validate. That's what lets seed scripts, bulk imports, and tests create users with deliberately-chosen passwords.- A custom registration flow (your own signup handler, a CLI importer that accepts untrusted input) should call
validate_passworditself beforecreate_user, just as the built-in route does. The helper trusts its caller.
use umbral_auth::{validate_password, PasswordContext, create_user, AuthError};use umbral::web::StatusCode; // A hand-rolled signup handler: validate at the boundary, then create.async fn signup(username: &str, email: &str, password: &str) -> Result<(), AuthError> { if let Err(reasons) = validate_password( password, &PasswordContext::new(Some(username), Some(email)), ) { // 400 with `reasons.join(" ")` in a real handler. return Err(AuthError::WeakPassword(reasons)); } create_user(username, email, password).await?; Ok(())}The default policy
Four validators run by default:
| Validator | Rejects |
|---|---|
MinLengthValidator(8) | passwords shorter than 8 characters |
CommonPasswordValidator | passwords in an embedded denylist of common passwords (case-insensitive) |
NumericPasswordValidator | passwords made up entirely of digits ("12345678") |
UserAttributeSimilarityValidator | passwords too similar to the username or email local-part |
use umbral_auth::{validate_password, PasswordContext}; // Aggregates ALL failures, not just the first.let result = validate_password( "alice123", &PasswordContext::new(Some("alice"), Some("alice@example.com")),);// Err(vec!["This password is too short. ...",// "This password is too similar to your username or email."])Customising the policy
Tune the policy on the plugin builder. The configured policy is installed ambiently at boot (on_ready), so the free-function helpers pick it up with no extra plumbing:
use umbral_auth::{AuthPlugin, AuthUser, PasswordPolicy, MinLengthValidator, CommonPasswordValidator}; // Convenience: keep the four defaults, just raise the minimum length.AuthPlugin::<AuthUser>::default().min_password_length(12) // Or replace the whole set with a hand-built policy.AuthPlugin::<AuthUser>::default().password_validators( PasswordPolicy::empty() .with(Box::new(MinLengthValidator(10))) .with(Box::new(CommonPasswordValidator)),)Write your own rule by implementing PasswordValidator:
use umbral_auth::{PasswordValidator, PasswordContext}; #[derive(Debug)]struct RequireSymbol; impl PasswordValidator for RequireSymbol { fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> { if password.chars().any(|c| !c.is_alphanumeric()) { Ok(()) } else { Err("Add at least one symbol.".to_string()) } }}Opting out
Secure-by-default means turning the policy off requires asking for it explicitly. disable_password_validation() installs an empty policy ambiently, so the register route's validate_password check becomes a no-op and any password is accepted:
// The register route accepts ANY password - for a throwaway demo or a// legacy import only.AuthPlugin::<AuthUser>::default().disable_password_validation()The design rationale lives in CLAUDE.md under "secure-by-default" and the M9 auth outline (docs/specs/outlines/auth-and-sessions.md).
Bootstrapping the first user: createsuperuser
AuthPlugin registers a createsuperuser management command for bringing up the first staff/admin account without checking credentials into source. Available from any binary that includes the plugin:
# Interactive - prompts for username, email, and password (no-echo, confirmed twice).cargo run -p my-app -- createsuperuser # Skip the username / email prompts; still prompts for password.cargo run -p my-app -- createsuperuser --username admin --email admin@example.com # Non-interactive - for CI / containers / declarative seed scripts. Reads the password# from UMBRAL_SUPERUSER_PASSWORD and fails fast if any required value is missing.UMBRAL_SUPERUSER_PASSWORD='Tr0ub4dour&3xpl' \ cargo run -p my-app -- createsuperuser \ --username admin --email admin@example.com --noinputThe new row lands with is_active = true, is_staff = true, is_superuser = true - the standard shape for the bootstrap admin. Password is argon2-hashed before insert.
Built-in HTTP auth routes
AuthPlugin::with_default_routes() mounts the four most common auth endpoints under the default /api/auth prefix. To choose a different prefix, use with_default_routes_at("/auth"). Only available on AuthPlugin<AuthUser> - custom user models bring their own routes.
App::builder() // Default prefix `/api/auth`: .plugin(AuthPlugin::<AuthUser>::default().with_default_routes()) // Or pick your own prefix: // .plugin(AuthPlugin::<AuthUser>::default().with_default_routes_at("/auth")) .build()?;| Endpoint | Behaviour |
|---|---|
POST /api/auth/register | Create a new user. 201 on success, 409 if username or email is taken (DB-enforced via the UNIQUE constraint on both columns). |
POST /api/auth/login | Verify credentials and create a session via umbral-sessions. 200 + Set-Cookie on success, 401 on invalid credentials or inactive account (same error so callers can't enumerate accounts). |
POST /api/auth/logout | Destroy the current session row and clear the cookie. 204 either way - calling it without a session is a no-op. |
GET /api/auth/me | Return the current user (session-first, then bearer). 200 + user JSON when authenticated; 401 otherwise. |
The OpenAPI plugin picks these up automatically and publishes their schemas alongside your own routes.
Rate limiting / throttle
The built-in login and register routes are throttled by default - a sliding-window limiter that brakes credential-stuffing and brute-force without any configuration. It's secure-by-default in the same spirit as password validation: an app has to opt OUT explicitly. Under the hood it's the same core umbral::ratelimit::RateLimiter primitive that backs umbral-rest's API throttles - one limiter implementation shared across the framework.
The defaults
| Route | Budget | Key | On breach |
|---|---|---|---|
POST /api/auth/login | 5 attempts / 5 min | client IP + username | 429 Too Many Requests |
POST /api/auth/register | 10 attempts / hour | client IP | 429 Too Many Requests |
Login keys on IP + username so one attacker IP can't lock out every account, and a targeted account can't be brute-forced from one IP. A successful login clears its counter, so a legitimate user who mistypes their password a couple of times isn't locked out by those failures. The 429 is identical whether or not the account exists - it never leaks account existence. The check runs before any DB work.
The client IP is read from X-Forwarded-For (first hop), then X-Real-IP. When neither is present (a direct, un-proxied connection), all such callers share one bucket - the safe side: it still limits, it never opens a hole.
Tuning the budgets
use std::time::Duration; AuthPlugin::<AuthUser>::default() .with_default_routes() .login_throttle(10, Duration::from_secs(300)) // 10 / 5 min per IP+username .register_throttle(20, Duration::from_secs(3600)) // 20 / hour per IPOpting out
AuthPlugin::<AuthUser>::default().disable_throttle()disable_throttle() turns login + register throttling off entirely. Reach for it only for a load test or an internal tool already fronted by its own gateway limiter - not to silence a throttled test (use a distinct IP/username per attempt, or a generous login_throttle, instead).
The throttle store is in-memory and single-instance. Each replica counts independently, so in a multi-instance deployment the effective budget is max × replicas. That's still a real brake on an attacker pinned to one replica by a sticky load balancer, but an app that needs a hard global limit should front it with a shared limiter (a future Redis-backed Throttle).
The AuthUser shape
pub struct AuthUser { pub id: i64, pub username: String, // UNIQUE pub email: String, pub password_hash: String, pub is_active: bool, pub is_staff: bool, pub is_superuser: bool, pub date_joined: DateTime<Utc>, pub last_login: Option<DateTime<Utc>>,}authenticate filters on is_active = 1, so disabled accounts reject
unconditionally.
Guarding views
Gate handlers behind authentication with the LoggedIn<U> extractor or wrap a whole Router subtree with LoginRequiredLayer. Both share a LoginRequired config that picks between a 401 JSON response (for API endpoints) and a 302 redirect to /login?next=<uri> (for HTML pages):
use umbral_auth::{AuthUser, login_required::{LoggedIn, login_required_html}}; // Per-handler: extractorasync fn me(LoggedIn(user): LoggedIn<AuthUser>) -> Json<serde_json::Value> { Json(serde_json::json!({ "username": user.username }))} // Per-router: layerlet app = Router::new() .route("/dashboard", get(dashboard)) .layer(login_required_html("/login"));LoggedIn<U> is generic over any UserModel so it composes with the custom user model swap. Full guide: Guarding views in the auth plugin docs.
Custom user models
The AuthPlugin<U> type parameter selects the user model. Swap in any struct that implements UserModel:
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]pub struct TenantUser { pub id: i64, pub username: String, pub password_hash: String, pub is_active: bool, // ← convention: framework filters on this pub is_staff: bool, // ← convention: IsStaff permission reads it pub tenant_id: i64, // ← app-specific column} impl umbral_auth::UserModel for TenantUser { // `id()` returns `<Self as Model>::PrimaryKey` - the typed PK // the derive picks up from the `id` field. For `id: i64` that // is `i64`; for `id: uuid::Uuid` it would be `uuid::Uuid`. fn id(&self) -> i64 { self.id } fn username(&self) -> &str { &self.username } fn password_hash(&self) -> &str { &self.password_hash } fn set_password_hash(&mut self, h: String) { self.password_hash = h; } fn is_active(&self) -> bool { self.is_active } fn is_staff(&self) -> bool { self.is_staff } // `id_string()` is left as the trait default - uses `Display` // from `PrimaryKey`, which i64 / Uuid / String all provide.}A UUID-keyed user model
UserModel is polymorphic over the underlying primary-key type via the existing Model::PrimaryKey associated item. A UUID-keyed user works without a single parse::<i64> anywhere in the framework:
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]pub struct UuidUser { pub id: uuid::Uuid, pub username: String, pub password_hash: String, pub is_active: bool,} impl umbral_auth::UserModel for UuidUser { fn id(&self) -> uuid::Uuid { self.id } fn username(&self) -> &str { &self.username } fn password_hash(&self) -> &str { &self.password_hash } fn set_password_hash(&mut self, h: String) { self.password_hash = h; } fn is_active(&self) -> bool { self.is_active }} App::builder() .plugin(AuthPlugin::<UuidUser>::default()) .plugin(SessionsPlugin::default()) .build()?The framework parses the session row's text user_id back to <U::PrimaryKey as FromStr> - for UuidUser that's uuid::Uuid::from_str, no i64 step in between. set_password::<UuidUser> writes through with a UUID-shaped WHERE clause; Identity::user_id carries the canonical UUID string so REST permission checks compose as usual.
Wiring TenantUser
App::builder() .plugin(umbral_auth::AuthPlugin::<TenantUser>::default()) .plugin(umbral_sessions::SessionsPlugin::default()) .build()?What works for any UserModel (generic over U):
umbral_auth::create_user,authenticate,set_password,hash_password- already generic.LoggedIn<U>extractor +login_required<U>middleware - already generic.umbral_auth::current_user_as::<U>(&headers)- the generic-over-U version ofcurrent_user. Reads the cookie, joins to the U table, returnsOption<U>. Drop into any custom handler.umbral_sessions::login_user_id(req, resp, Some(user.id().to_string()))- write the session row + Set-Cookie for any user model. Stringifies the PK viaDisplayso it round-trips through the polymorphicsession.user_idtext column (gap #59).
Convention current_user_as::<U> assumes: the model has an id column populated with U::PrimaryKey (any type implementing PrimaryKey + FromStr - i64, uuid::Uuid, String) and an is_active boolean column. Custom user models that rename either column write their own resolver against umbral_sessions::current_user_id_str and a hand-written QuerySet filter.
What's AuthUser-only:
AuthToken+BearerAuthentication- FKs intoauth_userdirectly viaForeignKey<AuthUser>. Custom user models needing bearer-token auth bring their own token model + their ownAuthenticationimpl.AuthPlugin::with_default_routes()- mounts/api/auth/{register,login,logout,me}againstAuthUserspecifically. Only available onAuthPlugin<AuthUser>(compile-time gate via a concrete impl block).umbral_auth::current_user,umbral_auth::login,umbral_auth::login_with_request,umbral_auth::User,umbral_auth::OptionalUser-AuthUser-shaped convenience wrappers. The generic counterparts live next to each one (current_user_as<U>,login_user_id,LoggedIn<U>).
Worked example for a TenantUser login flow:
async fn login_tenant( headers: HeaderMap, Json(body): Json<LoginIn>,) -> Result<Response, StatusCode> { // umbral_auth::authenticate is generic over U: UserModel. let user: TenantUser = umbral_auth::authenticate(&body.username, &body.password) .await .map_err(|_| StatusCode::UNAUTHORIZED)?; let mut response = Json(serde_json::json!({ "tenant_id": user.tenant_id })).into_response(); umbral_sessions::login_user_id( &headers, response.headers_mut(), Some(user.id().to_string()), ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(response)} async fn me_tenant(headers: HeaderMap) -> Result<Json<TenantUser>, StatusCode> { let user: TenantUser = umbral_auth::current_user_as::<TenantUser>(&headers) .await .ok_or(StatusCode::UNAUTHORIZED)?; Ok(Json(user))}What ships now vs deferred
Shipped:
- Custom user model swap via the
UserModeltrait and genericAuthPlugin<U>. - The
LoggedIn<U>request extractor andLoginRequiredLayertower middleware (above). createsuperusermanagement command (above).AuthPlugin::with_default_routes()(orwith_default_routes_at(prefix)) mountsregister/login/logout/me(above).- Login / logout / password-reset HTTP flows are integrated through
umbral-sessionsandumbral-email. - Permissions, groups, and RBAC -
ContentType,Permission,Group,has_perm(user_id, "app.codename")- via theumbral-permissionsplugin. permission_required("app.codename")axum layer (andpermission_required_htmlfor HTML redirect flows) - lives inumbral-permissions, composes on the same router asLoginRequiredLayer. Authenticates and checks the codename in one pass.- Admin UI for managing groups and permissions - the
umbral-adminplugin auto-registers screens forAuthUser,Group,Permission,UserGroup, andUserPermission.
Deferred to later milestones:
staff_member_requiredaxum layer - useuser.is_staff()in a handler or theIsStaffpermission class onResourceConfigfor now.