This version is in beta. Some features may change before release.

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

Code
rust
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

Code
rust
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?;
 
// Authenticate
let 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.

Info

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 register route calls validate_password up front and returns a 400 (carrying every failure reason, not just the first) before any row is written.
  • create_user, create_user_with_flags, create_superuser, and set_password are 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_password itself before create_user, just as the built-in route does. The helper trusts its caller.
Code
rust
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:

ValidatorRejects
MinLengthValidator(8)passwords shorter than 8 characters
CommonPasswordValidatorpasswords in an embedded denylist of common passwords (case-insensitive)
NumericPasswordValidatorpasswords made up entirely of digits ("12345678")
UserAttributeSimilarityValidatorpasswords too similar to the username or email local-part
Code
rust
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:

Code
rust
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:

Code
rust
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:

Code
rust
// The register route accepts ANY password - for a throwaway demo or a
// legacy import only.
AuthPlugin::<AuthUser>::default().disable_password_validation()
Warning
Don't reach for `disable_password_validation()` to silence a failing test or seed script - fix the fixture's password instead. An empty policy is a real security decision, not a convenience.

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:

Code
bash
# 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 --noinput

The 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.

Warning
`--noinput` is the only path that reads `UMBRAL_SUPERUSER_PASSWORD`. Without it the command prompts (twice, with no-echo) - convenient for interactive setup but a CI footgun if you forget the flag.

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.

Code
rust
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()?;
EndpointBehaviour
POST /api/auth/registerCreate 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/loginVerify 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/logoutDestroy the current session row and clear the cookie. 204 either way - calling it without a session is a no-op.
GET /api/auth/meReturn 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

RouteBudgetKeyOn breach
POST /api/auth/login5 attempts / 5 minclient IP + username429 Too Many Requests
POST /api/auth/register10 attempts / hourclient IP429 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

Code
rust
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 IP

Opting out

Code
rust
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).

Warning

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

Code
rust
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):

Code
rust
use umbral_auth::{AuthUser, login_required::{LoggedIn, login_required_html}};
 
// Per-handler: extractor
async fn me(LoggedIn(user): LoggedIn<AuthUser>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "username": user.username }))
}
 
// Per-router: layer
let 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:

Code
rust
#[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:

Code
rust
#[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

Code
rust
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 of current_user. Reads the cookie, joins to the U table, returns Option<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 via Display so it round-trips through the polymorphic session.user_id text 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 into auth_user directly via ForeignKey<AuthUser>. Custom user models needing bearer-token auth bring their own token model + their own Authentication impl.
  • AuthPlugin::with_default_routes() - mounts /api/auth/{register,login,logout,me} against AuthUser specifically. Only available on AuthPlugin<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:

Code
rust
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 UserModel trait and generic AuthPlugin<U>.
  • The LoggedIn<U> request extractor and LoginRequiredLayer tower middleware (above).
  • createsuperuser management command (above).
  • AuthPlugin::with_default_routes() (or with_default_routes_at(prefix)) mounts register / login / logout / me (above).
  • Login / logout / password-reset HTTP flows are integrated through umbral-sessions and umbral-email.
  • Permissions, groups, and RBAC - ContentType, Permission, Group, has_perm(user_id, "app.codename") - via the umbral-permissions plugin.
  • permission_required("app.codename") axum layer (and permission_required_html for HTML redirect flows) - lives in umbral-permissions, composes on the same router as LoginRequiredLayer. Authenticates and checks the codename in one pass.
  • Admin UI for managing groups and permissions - the umbral-admin plugin auto-registers screens for AuthUser, Group, Permission, UserGroup, and UserPermission.

Deferred to later milestones:

  • staff_member_required axum layer - use user.is_staff() in a handler or the IsStaff permission class on ResourceConfig for now.