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

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

Code
toml
# Cargo.toml
umbral-auth = { path = "../../plugins/umbral-auth" }
Code
rust
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

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

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

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

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

Code
bash
cargo run -- createsuperuser --username admin --email admin@example.com --noinput
# reads password from UMBRAL_SUPERUSER_PASSWORD

The AuthPlugin builder surface

AuthPlugin::<U>::default() is secure out of the box; the builder methods are tweaks on top, all chainable:

MethodEffect
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:

Code
rust
// REST/API: return 401 JSON + WWW-Authenticate: Bearer
LoginRequired::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.

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

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

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

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

ShapeWhen to reach for it
LoginRequired::APIREST 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_user works.

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

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

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

MethodRequiredDefaultUsed by
id() -> <Self as Model>::PrimaryKeyyes-set_password WHERE clause
id_string() -> StringnoPK's Displaysessions / REST Identity user id
username() -> &stryes-authenticate SELECT
password_hash() -> &stryes-authenticate verify
set_password_hash(String)yes-set_password in-place update
is_active() -> boolnotrueauthenticate active-user gate
is_staff() -> boolnofalseadmin require_staff check
is_superuser() -> boolnofalsepermission 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:

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

Code
rust
has_perm(&user.id_string(), "blog.publish_post").await?;

Step 3 - wire the plugin

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

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

Code
rust
use umbral_auth::authenticate;
 
let user = authenticate::<TenantUser>("alice", "s3cret").await?;
// Returns TenantUser on success; AuthError::InvalidCredentials on failure.

set_password is also generic:

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

Code
rust
AuthPlugin::<TenantUser>::default().user_model_name("tenant_user")

What carries over automatically

After swapping the user model:

  • authenticate::<TenantUser> - works against tenant_user table.
  • set_password(&mut user, ...) - works against tenant_user table.
  • AuthPlugin::<TenantUser> models() - registers TenantUser for 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> - queries auth_user by id. With a custom user model you replace this call with your own lookup against tenant_user.
  • umbral_auth::login(headers, &user) - takes &AuthUser. Call umbral_sessions::create_session(Some(user.id().to_string()), None) and umbral_sessions::set_cookie_header(...) directly instead.
  • umbral_auth::{User, OptionalUser} extractors (in umbral_auth::session_user) resolve AuthUser from the session. Replace with your own extractor that queries tenant_user.
  • umbral_admin - its require_staff check calls umbral_auth::authenticate which returns AuthUser. 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

  • authenticate returns 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 = false or is_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_staff gate and how it relates to is_staff.
  • Permissions (RBAC) - ContentType, Permission, Group, and has_perm(user_id, "app.codename").
  • Web → Auth gating - LoggedIn<U> and LoginRequiredLayer quick reference.
authuserspasswordscustom-user-model