user in templates
Inject the current user into every HTML template's context (`is_authenticated`, `is_staff`, and the full serialized AuthUser) via one builder method on AuthPlugin.
With one builder call, umbral injects the current user into every HTML template's context, so templates can write {% if user.is_authenticated %} and {% if user.is_staff %} directly. It's opt-in.
The one-line opt-in
.plugin( AuthPlugin::new() .with_default_routes() .with_user_in_templates() // ← here)That's it. The user global is always present in every umbral::templates::render(...) call (handler templates, plugin templates, error pages) because umbral-core injects it unconditionally. What .with_user_in_templates() changes is what authenticated requests see: with the opt-in, a logged-in request gets the full serialized user row; without it, every request (logged-in or not) gets the anonymous sentinel.
What user looks like in your template
Anonymous request (no session cookie / expired session / user_id IS NULL on the session row):
{% if user.is_authenticated %} ...{% else %} <a href="/login/">Sign in</a> {# ← this branch fires #}{% endif %}user is { "is_authenticated": false, "is_staff": false, "is_superuser": false }. These three boolean keys are the only shape an anonymous user carries, so any {% if user.is_staff %} / {% if user.is_superuser %} gate evaluates to false instead of throwing.
Authenticated request: user is the full serialized AuthUser (every field on the model) plus "is_authenticated": true:
{% if user.is_authenticated %} <span>Hi, {{ user.username }}</span> {% if user.is_staff %} <a href="/admin/">Admin</a> {# ← gates the staff link #} {% endif %} {% if user.is_superuser %} <a href="/internal/">Internal tools</a> {% endif %}{% endif %}Every column on AuthUser is accessible: user.email, user.date_joined, user.last_login, and so on. A custom UserModel (the AuthPlugin::<MyUser> form) needs its own template wiring. See Custom user models below.
Why opt-in
Each request that touches a template pays one DB read (cookie to session to user row). A REST-only service has no templates and nothing to gain, so leaving the middleware off keeps the per-request cost where it belongs. HTML-heavy apps turn it on once at boot and forget about it.
What's happening under the hood
.with_user_in_templates() flips a flag on the plugin. At build time, AuthPlugin::wrap_router (a Plugin::wrap_router override) wraps the whole merged router in axum::middleware::from_fn(user_context_layer):
Request → [user_context_layer] ↓ current_user(&headers).await ↓ serialize(user) merged with {is_authenticated: true} ↓ umbral::templates::with_current_user(value, next.run(req)).await ↓ [handler] → umbral::templates::render(...) → sees `user` in ctxThe middleware lives at plugins/umbral-auth/src/session_user.rs:263. It uses a tokio task-local (CURRENT_USER) scoped for the duration of the request so the rendered context is per-request, not global.
user is always defined in templates: umbral-core's renderer injects it unconditionally. Without .with_user_in_templates(), it's the anonymous sentinel { is_authenticated: false, is_staff: false, is_superuser: false } for every request, even authenticated ones, because no middleware populates the per-request user. So {% if user.is_staff %} evaluates cleanly (to false) rather than throwing an "undefined value" 500. What .with_user_in_templates() adds is the live, authenticated shape: it mounts the middleware that replaces the sentinel with the real serialized user for logged-in requests.
Common patterns
Hide a link behind a staff check
{% if user.is_staff %} <a href="/admin/">Admin</a>{% endif %}Show a different greeting based on auth state
{% if user.is_authenticated %} Welcome back, {{ user.username }}.{% else %} <a href="/login/">Sign in</a> to track your orders.{% endif %}Branch on any AuthUser column
{% if user.is_authenticated and user.is_superuser %} <div class="banner banner-warning"> You're signed in as a superuser. Anything you do here is logged. </div>{% endif %}Use a custom display name from your own table
The serialized shape carries every column on AuthUser. If you've added a display_name column via a custom user model, it's available the same way:
<span>{{ user.display_name or user.username }}</span>Related objects: forward-FK and reverse-OneToOne traversal
with_user_in_templates() expands the serialized user with its related single objects, up to two hops, so a template can dot-walk into them without the handler pre-resolving anything:
{# reverse-OneToOne: a Customer with a UNIQUE FK to AuthUser #}<p>Loyalty points: {{ user.customer.loyalty_points }}</p> {# two hops: reverse-OneToOne, then a forward FK on that child #}<p>City: {{ user.customer.address.city }}</p>How the keys are named:
- Forward FK — a foreign-key column resolves to its full target row under the column's own name (
user.<fk_field>.<column>). - Reverse-OneToOne — a child model with a UNIQUE FK pointing at
auth_useris injected under the child table's name (Customer { user: ForeignKey<AuthUser> (unique) }→user.customer). An ambiguous match (two UNIQUE FKs to the user, e.g.primary_user+backup_user) is skipped — there's no single right answer for which becomesuser.customer.
The walk is depth-bounded at 2 hops (user.customer.address is the last resolvable step) with (table, pk) cycle detection, and it costs one query per resolved relation per request — the honest price of "templates get relations for free." Sparse graphs (the common case) add 1–3 queries; deeper or wider graphs hit the cap and stop.
Reverse-FK one-to-many lists: opt-in
The list of children pointing at the user through a non-unique FK ({{ user.order_set }}) is not auto-expanded — it's an unbounded per-request query (a user with 50k orders would otherwise load all of them into every render). You opt each list in explicitly with expand_list::<Child>():
AuthPlugin::<AuthUser>::default() .with_user_in_templates() .expand_list::<Order>() // surfaces `user.order_set`{% for o in user.order_set %} <li>{{ o.total }}</li>{% endfor %}The list is injected under user.<child_table>_set (the same _set suffix as the Rust reverse-FK accessor) and is capped — at most 20 rows, ordered by primary key — so it can never become an unbounded render cost. Its items are the flat child rows (their own columns), not further-expanded. Need a filtered, ordered, or larger slice? Resolve it in the handler instead:
let orders = Order::objects().filter(order::USER.eq(&user.0.id)).limit(50).fetch().await?;render("me.html", &context!(username, orders))Everything else — columns on AuthUser itself (is_authenticated, is_staff, username, email, date_joined, …), the forward-FK / reverse-O2O relations above, and any expand_list you declared — is available on user directly. M2M sets and computed fields still have to be resolved in the handler.
Custom user models
The default wiring is AuthPlugin::new().with_user_in_templates(). The middleware is hard-bound to AuthUser because the serialization step uses its concrete Serialize impl. Apps using a custom UserModel build their own middleware against the building blocks:
umbral_sessions::current_session(&headers)→Option<SessionRow>current_session.user_id→ your custom user's PK- Your own
MyUser::objects().filter(...).first().await umbral::templates::with_current_user(my_user_value, next).awaitto push it into the per-request task-local
See the source of user_context_layer in plugins/umbral-auth/src/session_user.rs for a 30-line reference impl.
When to use this vs. extractors
For handler-level user access, extractors are still the right shape:
async fn dashboard(LoggedIn(user): LoggedIn<AuthUser>) -> Result<Html<String>, StatusCode> { let body = render("dashboard.html", &context!(username => user.username)) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Html(body))}The extractor and the template global serve different purposes:
| Use case | Tool |
|---|---|
| Handler logic needs the user (DB queries, branching) | LoggedIn<AuthUser> / OptionalUser extractors |
| Template needs to render conditionally based on user | .with_user_in_templates() global |
| Both | Both. They don't conflict. The extractor reads the user fresh from the session per-handler; the global is populated by the middleware that runs before any handler |
Pages that include partials (the navbar in a wrapper, a sidebar everyone sees) are why the template global exists: every handler shouldn't have to thread user into every template's context for the wrapper to render correctly.
Related
- Login, logout, request.user: extractors for handler-level user access
- Users and passwords: the
AuthUsermodel + password hashing - Plugin trait: what
wrap_routeris and how middleware is contributed