Authentication
umbral-auth plugin - AuthUser model, argon2 password hashing, session login helpers, and the custom user model swap.
Authentication
The umbral-auth plugin is the built-in authentication layer. It ships a ready-to-use AuthUser model, argon2 password hashing, and the helpers that power the login flow. Structurally it is a plugin like any other: registering it with App::builder().plugin(AuthPlugin::default()) is all you need.
Quick start
# Cargo.tomlumbral-auth = { path = "../../plugins/umbral-auth" }use umbral::prelude::*;use umbral_auth::AuthPlugin; let app = App::builder() .settings(settings) .database("default", pool) .plugin(AuthPlugin::default()) .build()?;Running migrate after registration creates the auth_user table.
The AuthUser model
pub struct AuthUser { pub id: i64, pub username: String, pub email: String, pub password_hash: String, // argon2 PHC string, never the plaintext pub is_active: bool, pub is_staff: bool, pub is_superuser: bool, pub date_joined: DateTime<Utc>, pub last_login: Option<DateTime<Utc>>,}AuthUser implements the Model trait so it participates in the normal QuerySet API.
Password helpers
use umbral_auth::{hash_password, verify_password}; // Hash before storing.let hash = hash_password("hunter2")?;// -> "$argon2id$v=19$m=19456,t=2,p=1$..." // Verify on login.let ok: bool = verify_password("hunter2", &hash)?;hash_password uses argon2id with a random per-password salt. The PHC string is self-describing, so future parameter upgrades are backward-compatible: a hash made with old parameters still verifies, and you can re-hash on next login.
Create and authenticate users
use umbral_auth::{create_user, authenticate, set_password}; // Create a regular user.let user = create_user("alice", "alice@example.com", "s3cret").await?; // Authenticate.let user = authenticate::<AuthUser>("alice", "s3cret").await?;// Returns Err(AuthError::InvalidCredentials) on wrong password OR unknown user.// The same error for both so a caller cannot enumerate accounts. // Rotate password.set_password(&mut user, "new-s3cret").await?;create_superuser and create_user_with_flags are also available for seed scripts and the createsuperuser CLI command.
Sessions integration
umbral-auth provides the AuthUser-aware login / logout bundle (it builds on umbral-sessions' user-agnostic primitives but lives in umbral-auth so it can name AuthUser):
use umbral_auth::authenticate;use umbral_auth::AuthUser;use umbral_auth::login; async fn login_handler(Form(form): Form<LoginForm>) -> Result<Response, StatusCode> { let user = authenticate::<AuthUser>(&form.username, &form.password) .await .map_err(|_| StatusCode::UNAUTHORIZED)?; let mut response = Redirect::to("/").into_response(); login(response.headers_mut(), &user) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(response)}umbral_auth::current_user(&headers) and the umbral_auth::{User, OptionalUser} extractors resolve the session cookie back to an AuthUser. (login_with_request is the variant that also preserves an anonymous session's data across the login and bumps last_login.) See the Sessions page for the session-row mechanics.
The createsuperuser management command
AuthPlugin contributes a createsuperuser subcommand via Plugin::commands(). From a project directory:
cargo run -- createsuperuser --username admin --email admin@example.com --noinput# reads password from UMBRAL_SUPERUSER_PASSWORDThe AuthPlugin builder surface
AuthPlugin::<U>::default() is secure out of the box; the builder methods are tweaks on top, all chainable:
| Method | Effect |
|---|---|
user_model_name(name) | Informational label surfaced in admin / OpenAPI. No effect on dispatch. |
with_user_in_templates() | Mount the user_context_layer so every HTML template render gets user in its global context. One DB read per request; off by default (pointless for REST-only apps). |
with_default_routes() / with_default_routes_at(prefix) | Mount register / login / logout / me under /api/auth (or your prefix). AuthPlugin<AuthUser> only. |
password_validators(policy) | Replace the default password-strength policy with a hand-built PasswordPolicy. |
min_password_length(n) | Keep the four default validators, change only the minimum length. |
disable_password_validation() | Explicit opt-out: install an empty policy so no validation runs. |
login_throttle(max, window) / register_throttle(max, window) | Tune the login / register rate-limit budgets. |
disable_throttle() | Explicit opt-out: turn login + register throttling off entirely. |
Password strength and rate limiting are both on by default and run at the registration / login boundary, not in the low-level creation helpers. Full coverage of the validators, the throttle budgets, and the opt-outs is on the Users and passwords page.
Guarding views
umbral gates a view behind authentication in two complementary shapes: a per-handler extractor and a per-Router layer. Both share one config struct that controls the unauthenticated response.
The LoginRequired config
Two constants/constructors choose the unauthenticated response:
// REST/API: return 401 JSON + WWW-Authenticate: BearerLoginRequired::API // HTML: 302 redirect to /login?next=<original-uri>LoginRequired::html("/login")LoginRequired::html always appends a ?next=<uri> parameter so the login handler can redirect back after success. Drop it with .no_next() if your login page does not use it.
Per-handler: LoggedIn<U> extractor
Drop LoggedIn<U> into a handler signature. The handler only runs when the request carries a valid, non-expired, authenticated session. On failure the extractor returns the configured rejection response.
use umbral_auth::{AuthUser, login_required::LoggedIn}; // REST handler - 401 JSON when unauthenticated (default config).async fn me(LoggedIn(user): LoggedIn<AuthUser>) -> Json<serde_json::Value> { Json(serde_json::json!({ "username": user.username }))} // HTML handler - same extractor, different rejection when the// LoginRequiredLayer above it has set LoginRequired::html.async fn dashboard(LoggedIn(user): LoggedIn<AuthUser>) -> Html<String> { Html(format!("<h1>Hello, {}!</h1>", user.username))}LoggedIn<U> is generic over any U: UserModel, so a custom user model (see "Swapping the user model") uses LoggedIn<TenantUser> without extra adapters.
When used without a layer, the extractor falls back to LoginRequired::API (401 JSON). Pair it with LoginRequiredLayer to get the HTML redirect shape - the layer injects its config into request extensions and the extractor picks it up automatically.
Per-router: LoginRequiredLayer
Wrap a Router subtree with LoginRequiredLayer to gate every route in one call. Unauthenticated requests are rejected before the inner handler is called at all.
use umbral_auth::login_required::{login_required, login_required_html};use umbral::web::{Router, get}; // API subtree - 401 JSON.let api_routes = Router::new() .route("/api/me", get(me)) .route("/api/posts", get(list_posts)) .layer(login_required()); // HTML subtree - 302 to /login?next=<uri>.let app_routes = Router::new() .route("/dashboard", get(dashboard)) .route("/settings", get(settings_page)) .layer(login_required_html("/login")); // Open routes - no layer.let public_routes = Router::new() .route("/", get(home)) .route("/login", get(login_page)); let router = public_routes.merge(api_routes).merge(app_routes);login_required() and login_required_html(path) are convenience wrappers over LoginRequiredLayer::new(LoginRequired::API) and LoginRequiredLayer::new(LoginRequired::html(path)).
If you need to build the layer explicitly:
use umbral_auth::login_required::{LoginRequired, LoginRequiredLayer}; let layer = LoginRequiredLayer::new(LoginRequired::html("/auth/login").no_next());let router = LoginRequiredLayer::new(LoginRequired::html("/auth/login")) .apply(my_router);Both shapes side by side
The two shapes compose naturally. A Router subtree can use the layer for the broad gate and then use the extractor in specific handlers for fine-grained access to the user:
use umbral_auth::{AuthUser, login_required::{LoggedIn, login_required_html}};use umbral::web::{Router, get, Html}; // Layer gates the whole subtree (unauthenticated → 302 /login).// LoggedIn in the handler gives us the authenticated user.async fn dashboard(LoggedIn(user): LoggedIn<AuthUser>) -> Html<String> { Html(format!("<h1>Welcome back, {}!</h1>", user.username))} async fn profile_edit(LoggedIn(user): LoggedIn<AuthUser>) -> Html<String> { Html(format!("<form><!-- editing {} --></form>", user.username))} let protected = Router::new() .route("/dashboard", get(dashboard)) .route("/profile/edit", get(profile_edit)) .layer(login_required_html("/login"));The layer checks the session once at the middleware boundary; the extractor hydrates the full user struct in the handler. No double database round-trip - the layer only reads the session table; the extractor reads both session and auth_user (or your custom user table).
API vs html(...) - which to use
| Shape | When to reach for it |
|---|---|
LoginRequired::API | REST endpoints, JSON API, SPA backends. Clients inspect the status code and the WWW-Authenticate header. |
LoginRequired::html(url) | Server-rendered HTML pages. Browsers follow the 302 automatically; the login form reads ?next= to redirect back. |
Mix both in one app: wrap the /api/* subtree with login_required() and the /app/* subtree with login_required_html("/login").
See also
plugins/umbral-auth/src/login_required.rs- implementation with inline docs.- Sessions - how the session cookie is created and how
current_userworks.
Swapping the user model
umbral lets you replace the built-in user model with your own struct. You select it through the AuthPlugin<U> type parameter directly, so the swap is validated at compile time with no runtime reflection involved.
Why it exists
The built-in AuthUser covers 90% of projects but forces you into its fixed column set. A multi-tenant SaaS needs a tenant_id column. A B2B app might want a display_name. Any extra column means either: store it in a separate profile table (join overhead), or swap the user model.
Step 1 - declare a custom user struct
use serde::{Deserialize, Serialize};use umbral::orm::Model;use umbral_auth::UserModel; #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]pub struct TenantUser { pub id: i64, pub username: String, pub password_hash: String, // required by UserModel pub display_name: String, // extra field pub tenant_id: i64, // extra field pub is_active: bool,}Step 2 - implement UserModel
UserModel requires four methods. The flag methods (is_active, is_staff, is_superuser) have default implementations that return safe values, so you only override them when your struct actually stores them.
id() returns <Self as Model>::PrimaryKey, the type #[derive(Model)] infers from your id field (i64, uuid::Uuid, String, ...). For an i64 PK the body is just self.id.
impl UserModel for TenantUser { fn id(&self) -> <Self as umbral::orm::Model>::PrimaryKey { self.id } fn username(&self) -> &str { &self.username } fn password_hash(&self) -> &str { &self.password_hash } fn set_password_hash(&mut self, hash: String) { self.password_hash = hash; } // Override because this struct has the column. fn is_active(&self) -> bool { self.is_active } // is_staff and is_superuser default to false.}The full UserModel surface:
| Method | Required | Default | Used by |
|---|---|---|---|
id() -> <Self as Model>::PrimaryKey | yes | - | set_password WHERE clause |
id_string() -> String | no | PK's Display | sessions / REST Identity user id |
username() -> &str | yes | - | authenticate SELECT |
password_hash() -> &str | yes | - | authenticate verify |
set_password_hash(String) | yes | - | set_password in-place update |
is_active() -> bool | no | true | authenticate active-user gate |
is_staff() -> bool | no | false | admin require_staff check |
is_superuser() -> bool | no | false | permission gates |
username() is the login handle, not necessarily a username column
username() returns the value authenticate(handle, password) matches against. It is the user-supplied login handle, not literally a column called username. If your app logs in by email, return the email:
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]pub struct EmailUser { pub id: i64, #[umbral(unique, max_length = 320)] pub email: String, // the login handle pub password_hash: String, pub is_active: bool,} impl UserModel for EmailUser { fn id(&self) -> <Self as umbral::orm::Model>::PrimaryKey { self.id } fn username(&self) -> &str { &self.email } // ← email-as-handle fn password_hash(&self) -> &str { &self.password_hash } fn set_password_hash(&mut self, hash: String) { self.password_hash = hash; } fn is_active(&self) -> bool { self.is_active }}Then authenticate::<EmailUser>("alice@example.com", "s3cret").await? looks the row up by email. Which column serves as the login handle is a trait method on your struct, not a separate config string.
The framework never assumes your handle column is called username. The only requirement: a unique column the user supplies at login time, and you point username() at it.
User-id type and the permissions plugin
UserModel::id() returns the model's typed PrimaryKey, so a uuid::Uuid or String PK is supported directly. The stored-id surfaces are PK-agnostic: umbral-sessions stores user_id as Option<String> (the PK stringified, so a UUID or i64 both round-trip), and Identity from umbral-rest carries user_id: String. The trait's id_string() does that stringification for you using the PK's Display.
umbral-permissions is PK-agnostic too: its user_id columns are TEXT, and has_perm / user_perms take &str. Pass the stringified PK at the perm-call boundary:
has_perm(&user.id_string(), "blog.publish_post").await?;Step 3 - wire the plugin
use umbral_auth::AuthPlugin; let app = App::builder() .settings(settings) .database("default", pool) .plugin(AuthPlugin::<TenantUser>::default()) .build()?;AuthPlugin::<TenantUser> registers TenantUser as the model under the "auth" plugin slot. migrate creates the tenant_user table (the snake_case of the struct name) instead of auth_user.
Step 4 - create users and authenticate
There is no generic create_user for custom models because constructing a TenantUser from a fixed set of columns is not possible without knowing which extra columns exist. Provide your own:
pub async fn create_tenant_user( username: &str, display_name: &str, tenant_id: i64, plaintext: &str,) -> Result<TenantUser, umbral_auth::AuthError> { let hash = umbral_auth::hash_password(plaintext)?; let pool = umbral::db::pool(); let row = sqlx::query_as::<_, TenantUser>( "INSERT INTO tenant_user (username, password_hash, display_name, tenant_id, is_active) VALUES (?, ?, ?, ?, 1) RETURNING *", ) .bind(username) .bind(&hash) .bind(display_name) .bind(tenant_id) .fetch_one(&pool) .await?; Ok(row)}Authenticate with the generic helper:
use umbral_auth::authenticate; let user = authenticate::<TenantUser>("alice", "s3cret").await?;// Returns TenantUser on success; AuthError::InvalidCredentials on failure.set_password is also generic:
use umbral_auth::set_password; set_password(&mut user, "new-s3cret").await?;Optional: informational model name
The user_model_name builder method stores a human-readable label that admin / OpenAPI can surface. It has no effect on dispatch - the type parameter is the only thing that matters:
AuthPlugin::<TenantUser>::default().user_model_name("tenant_user")What carries over automatically
After swapping the user model:
authenticate::<TenantUser>- works againsttenant_usertable.set_password(&mut user, ...)- works againsttenant_usertable.AuthPlugin::<TenantUser>models() - registersTenantUserfor migrations.- The migration engine - produces the right CREATE TABLE DDL for
TenantUser's fields. hash_password/verify_password- model-agnostic, work unchanged.
What requires manual adjustment
The AuthUser-aware helpers in umbral-auth and umbral-admin reference AuthUser concretely:
umbral_auth::current_user(&headers) -> Option<AuthUser>- queriesauth_userby id. With a custom user model you replace this call with your own lookup againsttenant_user.umbral_auth::login(headers, &user)- takes&AuthUser. Callumbral_sessions::create_session(Some(user.id().to_string()), None)andumbral_sessions::set_cookie_header(...)directly instead.umbral_auth::{User, OptionalUser}extractors (inumbral_auth::session_user) resolveAuthUserfrom the session. Replace with your own extractor that queriestenant_user.umbral_admin- itsrequire_staffcheck callsumbral_auth::authenticatewhich returnsAuthUser. The admin plugin is not yet generic over the user model; this is a known limitation and a follow-up item.
The Identity concept from umbral-rest carries user_id: String (the session user PK stringified), so REST permission checks continue to work without changes. Only code that explicitly hydrates an AuthUser struct needs updating.
Security notes
authenticatereturns the same error (InvalidCredentials) for both "no such user" and "wrong password". A caller cannot enumerate accounts from the error alone.- Inactive users (
is_active = falseoris_active() -> false) cannot authenticate. The SQL filter and the trait method both apply. - Passwords are never stored or logged. Only the argon2 PHC hash reaches the database.
See also
docs/specs/outlines/auth-and-sessions.md- design rationale, deferred features.- Sessions - session creation,
current_user, extractors. - Admin - the
require_staffgate and how it relates tois_staff. - Permissions (RBAC) -
ContentType,Permission,Group, andhas_perm(user_id, "app.codename"). - Web → Auth gating -
LoggedIn<U>andLoginRequiredLayerquick reference.