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

Login, logout, request.user

Anonymous sessions on every visit, the one-call login, axum extractors for `request.user`, and flash messages.

umbral-auth verifies credentials. umbral-sessions stores sessions and reads cookies. Together they give you this shape: every browser has a session from the first request (anonymous), login transforms it into an authenticated session, and handlers extract the current user via type-driven extractors.

A session is a browser, not an authentication

A session identifies the browser. The same session row spans the user's whole visit:

Stateuser_id columnWhen
AnonymousNULLFrom the first page load. Backs flash messages, cart contents, CSRF tokens, anything that has to survive a single redirect.
AuthenticatedSome(user.id)After umbral_auth::login(...). Fresh token (session-fixation defense); the row's data survives the transition.

SessionsPlugin::default() auto-applies a tower middleware (session_layer) on the router so every request lands with a SessionToken extension. Anonymous flash messages, cart, etc. all "just work" - they're already there.

Code
rust
let app = App::builder()
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default()) // auto session-on-every-visit
.routes(Routes::new().get("/", home))
.build()?;

Opt-out for advanced setups (typically when you want sessions only under /app/* and not for /api/healthz):

Code
rust
.plugin(SessionsPlugin::default().without_auto_layer())
// then apply the layer to a sub-router yourself:
// some_subrouter.layer(axum::middleware::from_fn(umbral_sessions::session_layer))

Logging a user in

Code
rust
use umbral::prelude::*;
use umbral::web::{Form, IntoResponse, Redirect, StatusCode};
 
#[derive(serde::Deserialize)]
struct LoginForm {
username: String,
password: String,
}
 
async fn login(Form(form): Form<LoginForm>) -> Result<Response, StatusCode> {
let user: AuthUser = umbral_auth::authenticate(&form.username, &form.password)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
let mut response = Redirect::to("/").into_response();
umbral_auth::login(response.headers_mut(), &user)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(response)
}

umbral_auth::login(response_headers, &user) does three things in one call:

  1. Creates a session row for the user (via umbral_sessions::login_user_id).
  2. Sets the Set-Cookie header on the response (HttpOnly, Secure, SameSite=Lax, Max-Age=14 days by default).
  3. Updates auth_user.last_login to Utc::now() (best-effort).

It returns the raw session token so tests or instrumentation can read it. Production code ignores the return value.

login is the convenience form. To preserve an anonymous session's data (flash messages, cart) across login, pass the request headers too: umbral_auth::login_with_request(&request_headers, response.headers_mut(), &user).

Logging out

Code
rust
async fn logout(headers: HeaderMap) -> Response {
let mut response = Redirect::to("/").into_response();
umbral_sessions::logout(&headers, response.headers_mut())
.await
.ok(); // safe to call without a session
response
}

logout(request_headers, response_headers) mirrors the login shape:

  1. Reads the session cookie from the request.
  2. Destroys the session row (no-op if there's no cookie).
  3. Sets a Set-Cookie header with Max-Age=0 so the browser drops the cookie immediately.

request.user - User / OptionalUser extractors

axum extractors give you ergonomic access to the current user.

Code
rust
use umbral::prelude::*;
use umbral::web::{Html, IntoResponse, StatusCode};
use umbral_auth::{User, OptionalUser};
 
// Required - 401 if the request isn't authenticated. The handler
// literally cannot run without a logged-in user.
async fn dashboard(User(user): User) -> Html<String> {
Html(format!("<h1>Welcome, {}</h1>", user.username))
}
 
// Optional - runs either way; you decide what to do.
async fn home(OptionalUser(maybe): OptionalUser) -> Html<String> {
match maybe {
Some(user) => Html(format!("Hi, {}", user.username)),
None => Html(r#"<a href="/login">Log in</a>"#.into()),
}
}

Behind the scenes both extractors call umbral_auth::current_user(&headers). The difference is the rejection: User returns 401, OptionalUser is Infallible and just wraps Option<AuthUser>.

User / OptionalUser are the AuthUser-specific extractors. For a custom user model, reach for the generic LoggedIn<U> extractor (see Auth gating) instead.

Info

Why not a middleware that injects the user on every request? umbral uses the extractor pattern instead. Saying User(user): User in the handler signature makes the auth requirement visible at the call site: you can tell from the type whether a route is gated without reading the middleware stack. The cost (a DB round-trip per request that needs request.user) is the same either way.

Flash messages

umbral_sessions::Messages is an extractor that lets any handler push flash messages into the session and any later handler drain them. Backed by the session's data JSON column under a reserved key (_umbral_messages).

Code
rust
use umbral::prelude::*;
use umbral::web::{Form, IntoResponse, Redirect};
use umbral_sessions::Messages;
 
async fn save_post(messages: Messages, Form(form): Form<PostForm>) -> Response {
Post::objects().create(/* ... */).await.unwrap();
messages.success("Post saved.").await;
Redirect::to("/posts").into_response()
}
 
async fn list_posts(messages: Messages) -> Html<String> {
let flash = messages.drain().await; // pulls all + clears
// render the template with `messages = flash`; each carries
// `.level` (`success` / `info` / `warning` / `error` / `debug`)
// and `.text`.
render("posts/list.html", &context!(messages => flash, /* ... */))
}

Methods on Messages:

MethodEffect
.add(level, text)Append at the explicit level.
.success(text) / .info(text) / .warning(text) / .error(text) / .debug(text)Convenience wrappers for each level.
.peek()Read the queue without clearing.
.drain()Read + clear. The usual flow.
.is_active()True when there's a session backing this request.

The five levels (Debug / Info / Success / Warning / Error) serialise as their lowercase names so templates can do class="alert alert-{{ msg.level }}" directly.

Info

Anonymous flash messages just work. With SessionsPlugin::default() auto-applied (the default), every browser has a session from its first visit. Flash messages added by anonymous handlers survive the redirect cycle naturally, including across login - the existing session's data carries over to the post-login session when the fixation defense regenerates the token.

The reserved key _umbral_messages inside session.data is owned by this framework - don't write to it via set_data directly, use Messages.

See also