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

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.

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

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

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

Code
jinja
{% 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:

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

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

Code
rust
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_key is required: a stateless cookie session derived from an empty key is trivially forgeable. With an empty secret_key, CookieStore warns in dev/test and error-shouts in production - set secret_key in umbral.toml or via UMBRAL_SECRET_KEY before deploying.
  • Size limit. Cookies can't exceed browser limits, so the encoded value is capped at ~4 KB. A save that would exceed it returns SessionError::CookieTooLarge rather than silently producing a cookie the browser drops - keep the session small.
Warning
`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.

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:

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

Code
toml
umbral-sessions = { path = "…", features = ["redis"] }

Then connect at boot:

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

Code
rust
let store = RedisStore::from_env().await?;

How it works:

  • Key format. umbral:session:<sha256hex(token)> - same hash-before-storage invariant as DbStore. The raw token never leaves the cookie.
  • Server-side TTL. save calls SET … EX <seconds> where the TTL is record.expires_at - now(). Redis evicts the key automatically when the TTL fires - no sweep job, no clearsessions cron.
  • Lazy expiry double-check. load still compares expires_at against the current time after deserialising the record, and issues a DEL if the record is past its time even though Redis hasn't evicted it yet (clock skew between processes).
  • Idempotent destroy. DEL on a non-existent key is a no-op in Redis, so destroy is always safe to call regardless of session state.

When to choose RedisStore over DbStore / CookieStore:

DbStoreCookieStoreRedisStore
LatencySQL round-tripZero (cookie)µs (Redis)
Server-side revocationYesNoYes
Scales horizontallyVia DBYesYes
Needs extra serviceNoNoYes (Redis)
Cookie size limitNo~4 KBNo

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 and LoginRequiredLayer for protecting routes.
  • Permissions (RBAC) - group-based and direct permission grants.