Sessions plugin
Cookie-backed session store powered by a DB-backed session table.
umbral-sessions gives you a DB-backed session table, a UUID cookie, and helpers
for creating, reading, and destroying sessions - enough to implement login/logout
without any additional infrastructure.
What you get from SessionsPlugin::default()
Registering the plugin creates one migration: a session table with columns id (the SHA-256 digest of the raw cookie token - the raw token only ever lives in the cookie, so a DB leak can't replay sessions), user_id (nullable, the user PK stringified - TEXT, not a typed FK, so any PK type round-trips), data (JSON string), created_at, and expires_at. The plugin has no hard dependency on umbral-auth; anonymous sessions are valid. The umbral_session cookie is set with Secure, HttpOnly, SameSite=Lax, and Max-Age=1209600 (14 days by default).
By default SessionsPlugin auto-applies its session_layer middleware through Plugin::wrap_router. The layer creates a session row lazily on first write: every request gets a session token in memory, but the row is only persisted when something actually writes the session (set_data, Messages, login). A cookie-less request that never writes the session (a favicon, a CSS/JS asset, an anonymous read-only page) leaves no row and sets no Set-Cookie. The first write materialises exactly one row and emits the cookie. Call .without_auto_layer() to skip the layer entirely and apply it only to a sub-router.
App::builder() .plugin(AuthPlugin::default()) .plugin(SessionsPlugin::default()) .build()?;Reading the current user in a handler
The one-call helper current_user parses the cookie header, queries the session table (deleting the row lazily if expired), and hydrates the AuthUser. It lives in umbral-auth (it has to name AuthUser, which umbral-sessions is deliberately decoupled from). Use it as a guard at the top of any authenticated handler:
use umbral_auth::current_user;use umbral::web::{HeaderMap, Html, StatusCode}; async fn dashboard(headers: HeaderMap) -> Result<Html<String>, StatusCode> { let Some(user) = current_user(&headers).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? else { return Err(StatusCode::UNAUTHORIZED); }; Ok(Html(format!("<h1>Hello, {}</h1>", user.username)))}Expose the current user in every template
Opt in with .with_user_in_templates() and every template render in the app gets {{ user }} for free - no per-handler ctx threading. The method lives on AuthPlugin, not SessionsPlugin: the layer needs to name the concrete user model to hydrate it, which is umbral-auth's job. SessionsPlugin still has to be registered for the underlying session lookup to work:
App::builder() .plugin(AuthPlugin::<AuthUser>::default().with_user_in_templates()) .plugin(SessionsPlugin::default()) .build()?;Under the hood: an axum layer resolves the current user once per request and stashes the serialized value in a tokio task-local. umbral::templates::render reads the task-local and merges into the ctx under key user - but only when the handler didn't already supply its own user (explicit ctx wins).
Templates can branch uniformly:
{% if user.is_authenticated %} <span class="navbar-username">{{ user.username }}</span> <a href="/logout">Sign out</a>{% else %} <a href="/login">Sign in</a>{% endif %}The injected value is always at least { is_authenticated: false } for anonymous requests, so the is_authenticated check is safe without a separate {% if user %} guard.
Cost. One current_user DB read per request (cookie → session → user lookup). That's why it's opt-in: REST-only services, static-asset endpoints, and health checks all skip the lookup unless the app calls .with_user_in_templates().
Setting and reading per-session data
get_data and set_data round-trip typed values through the data JSON column.
Use them for flash messages, shopping-cart state, or any short-lived server-side
storage that doesn't need its own table:
// On login: create session, persist data, write the Set-Cookie header.// create_session takes the user PK stringified (Option<String>) and an// optional ttl (Option<Duration>); pass None to default to 14 days. It// returns the raw token to put in the cookie.let token = umbral_sessions::create_session(Some(user.id.to_string()), None).await?;umbral_sessions::set_data(&token, "cart_count", &0_u32).await?;let cookie = umbral_sessions::set_cookie_header(&token, None); // On a later request: read the data back.let session = umbral_sessions::read_session(&token).await?.unwrap();let count: Option<u32> = umbral_sessions::get_data(&session, "cart_count")?;Sliding (rolling) expiry
By default a session's expires_at is fixed at creation time. A session started on Monday with a 14-day TTL expires on Monday two weeks later, regardless of activity. Call .sliding_expiry() so that every request that resolves a live session extends expires_at to now + 14 days, so an actively-used session never hard-expires mid-use.
App::builder() .plugin(SessionsPlugin::default().sliding_expiry()) .build()?;Default is off - the default path incurs zero extra writes per request.
CookieStore (stateless sessions)
By default sessions are DB rows (DbStore): the cookie holds an opaque token and every request reads the row from the database. CookieStore flips that around - it is a stateless, AEAD-encrypted session-in-cookie store. The entire session record (user id, JSON data, timestamps) is encrypted and stuffed into the cookie value itself, so there is zero DB round-trip on load or save and no session table rows are ever created.
use umbral_sessions::{SessionsPlugin, CookieStore}; App::builder() .plugin(SessionsPlugin::default().store(CookieStore::new())) .build()?;Reach for it when you want sessions without a database hit per request (read-heavy apps, edge deployments, horizontally-scaled stateless nodes). The trade-off is that the session can't be revoked server-side and is capped by the cookie size limit.
How it works:
- Cipher. XChaCha20Poly1305 (AEAD) with a fresh 24-byte random nonce per save. The cookie value is
base64url(nonce || ciphertext). AEAD means the same key both encrypts and authenticates, so a tampered or forged cookie fails the auth check and is treated as no session (the request resolves anonymous - it never errors). - Key. The 256-bit key is
SHA-256(secret_key).secret_keyis required: a stateless cookie session derived from an empty key is trivially forgeable. With an emptysecret_key,CookieStorewarns in dev/test and error-shouts in production - setsecret_keyinumbral.tomlor viaUMBRAL_SECRET_KEYbefore deploying. - Size limit. Cookies can't exceed browser limits, so the encoded value is capped at ~4 KB. A
savethat would exceed it returnsSessionError::CookieTooLargerather than silently producing a cookie the browser drops - keep the session small.
Purging expired rows: clearsessions
Expired rows are deleted lazily when read_session encounters one. For long-running apps this means stale rows accumulate. The clearsessions management command bulk-deletes every row whose expires_at < now() and prints the count removed. Run it periodically via cron or umbral-tasks:
cargo run -p umbral-cli -- clearsessions# Deleted 312 expired session(s).The command is registered by SessionsPlugin via Plugin::commands() - no extra wiring required.
RedisStore (feature-gated)
RedisStore is a server-side, Redis-backed session store - a keyed store like DbStore (the raw token stays in the cookie; the record lives on the server) but with µs-latency lookups, horizontal scalability, and server-side TTL eviction: Redis auto-expires keys when the session's TTL fires, so no clearsessions job is needed.
Enable the redis cargo feature in your app's Cargo.toml:
umbral-sessions = { path = "…", features = ["redis"] }Then connect at boot:
use umbral_sessions::{SessionsPlugin, RedisStore}; let store = RedisStore::connect("redis://localhost:6379/0").await?;App::builder() .plugin(SessionsPlugin::default().store(store)) .build()?;Or read the URL from the environment (UMBRAL_REDIS_URL):
let store = RedisStore::from_env().await?;How it works:
- Key format.
umbral:session:<sha256hex(token)>- same hash-before-storage invariant asDbStore. The raw token never leaves the cookie. - Server-side TTL.
savecallsSET … EX <seconds>where the TTL isrecord.expires_at - now(). Redis evicts the key automatically when the TTL fires - no sweep job, noclearsessionscron. - Lazy expiry double-check.
loadstill comparesexpires_atagainst the current time after deserialising the record, and issues aDELif the record is past its time even though Redis hasn't evicted it yet (clock skew between processes). - Idempotent destroy.
DELon a non-existent key is a no-op in Redis, sodestroyis always safe to call regardless of session state.
When to choose RedisStore over DbStore / CookieStore:
DbStore | CookieStore | RedisStore | |
|---|---|---|---|
| Latency | SQL round-trip | Zero (cookie) | µs (Redis) |
| Server-side revocation | Yes | No | Yes |
| Scales horizontally | Via DB | Yes | Yes |
| Needs extra service | No | No | Yes (Redis) |
| Cookie size limit | No | ~4 KB | No |
Reach for RedisStore when you want instant server-side revocation (force-logout, security events), sub-millisecond session reads, or horizontal scale without a shared SQL pool.
The spec lives in docs/specs/02-plugin-contract.md. Source: plugins/umbral-sessions/src/lib.rs, plugins/umbral-sessions/src/cookie_store.rs, and plugins/umbral-sessions/src/redis_store.rs.
See also
- Auth plugin -
AuthUser, password hashing, custom user model. - Auth gating -
LoggedIn<U>extractor andLoginRequiredLayerfor protecting routes. - Permissions (RBAC) - group-based and direct permission grants.