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.
Install
cargo add umbral-sessionsWhat 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::new().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.
Absolute lifetime cap
Sliding expiry has no upper bound on its own: a session (or a stolen cookie) used at least once per TTL window never expires. Pair it with .max_session_age(secs) to set an absolute cap measured from created_at - once a session is older than that, read_session rejects and destroys it no matter how far sliding expiry has pushed expires_at, forcing re-authentication.
App::builder() // Slide the idle window, but force re-login at least every 7 days. .plugin(SessionsPlugin::default().sliding_expiry().max_session_age(7 * 24 * 60 * 60)) .build()?;Default is off (no cap) - behavior is unchanged unless you set it. A 0 or negative value also disables it.
Cookie SameSite policy
The session cookie is SameSite=Lax by default - sent on same-site requests and top-level cross-site GET navigations, the safe and compatible choice. Tune it with .same_site(..):
use umbral_sessions::{SessionsPlugin, SameSite}; App::builder() // Never send the cookie on any cross-site navigation (max CSRF resistance). .plugin(SessionsPlugin::default().same_site(SameSite::Strict)) .build()?;SameSite::Strict gives the strongest CSRF resistance but can break inbound links that expect a live session (e.g. following an emailed link into an authed page). SameSite::None is for cross-origin embedding and forces Secure (browsers reject None without it), so it only works over HTTPS. Default is Lax.
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 (or the insecure dev-default) key is trivially forgeable (anyone can mint a valid cookie for anyuser_id).SessionsPluginenforces this itself: when a secret-derivedCookieStoreis the active store and the environment isProd, an empty or dev-defaultsecret_keyhard-fails at boot (App::build()returns an error) rather than serving forgeable sessions - you do not needumbral-securityfor this guard. In dev/test the same condition only warns so local iteration isn't blocked. Setsecret_keyinumbral.tomlor viaUMBRAL_SECRET_KEYbefore deploying, or pin an explicit key withCookieStore::with_secret(...)(which doesn't depend on the ambient secret and so is exempt from the check). - 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.
CookieStore sessions live entirely in the client's cookie. There is no server row to invalidate, so a logout clears the cookie but a copy captured before logout stays valid until it expires. Rotate secret_key to mass-invalidate every outstanding cookie session.
"Log out everywhere" (revoke_user_sessions, which the password-reset flow calls) works on DbStore and RedisStore — both keep the session server-side, so every one of a user's sessions is deleted immediately. On the stateless CookieStore there is nothing server-side to delete, so revoke_user_sessions returns SessionError::RevocationUnsupported and the caller logs it loudly rather than silently pretending to have revoked. If you need reliable server-side revocation (e.g. to invalidate a stolen session on password reset), use DbStore or RedisStore.
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:
cargo add umbral-sessions --features redisThen 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.
Server-side revocation currently works only with DbStore. The built-in logout() and revoke_user_sessions() helpers (the latter is what a password reset calls to "log out everywhere") delete from the SQL session table directly rather than through the active store, so under RedisStore they do not remove the Redis key and under CookieStore there is nothing to remove. With RedisStore, a logged-out or password-reset session's cookie keeps authenticating until its TTL fires. Until this is routed through the store, treat RedisStore/CookieStore as not supporting instant server-side revocation via the built-in helpers.
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.