Manual contentsThe BasicsBrowse 113 chapters
Manual 15 min read

Sessions

The session is the per-user key/value bag that survives across requests on the same browser. Suprnova ships a database-backed driver out of the box, wires it in via SessionMiddleware, and exposes the active session through two free functions - session() for reads, session_mut() for writes. Use it whenever a value should outlive one request but not be something the URL or a JWT should carry.

How a request sees the session

SessionMiddleware runs on every request and does five things in order:

  1. Reads the session id and last successful activity-touch timestamp from the suprnova_session cookie (AES-256-GCM encrypted, bound to the cookie name). Tampered, undecryptable, or malformed cookies are treated as absent, and so is a value that another cookie wrote.
  2. Loads SessionData from the store only when a valid cookie names a session. Cookieless requests start with a clean in-memory session and do not issue a guaranteed database miss. A cookie whose row no longer exists is cleared without recreating an empty row. A store read error logs warn! and lets a state-free request continue, but a handler mutation then fails closed rather than overwrite unknown stored state.
  3. Ages flash data: _flash.old.* is dropped, _flash.new.* is renamed to _flash.old.*. After this step, anything the previous request flashed is readable; anything this request flashes will be readable next time.
  4. Binds the session into a task-local slot for the duration of the handler. session() and session_mut() look the slot up.
  5. After the handler returns, persists dirty session state or a bounded sliding-expiry touch, attaches a replacement encrypted cookie only after a successful write, and drains pending out-of-band cookies (for example, a freshly rotated remember-me cookie). A clean cookieless request does no session-store I/O and receives no session cookie.

Step 5 has one safety guarantee worth pulling out: if the session was modified this request and the store write fails, the response is replaced with a 500. Returning the handler's success would mean handing the client a cookie for state the database never recorded - the next request would load an empty session and the mutation (login, CSRF rotation, flash) would silently vanish. Read-only requests that fail only on a due last_activity touch log warn!, keep the existing cookie, and pass through.

Reading the session

use suprnova::session::session;

if let Some(s) = session() {
    let user_id: Option<String> = s.get("preferred_username");
    if s.has("cart") {
        // ...
    }
    if s.missing("locale") {
        // first visit
    }
}

session() clones the current SessionData. Returns None outside a request scope (a unit test that didn't install the middleware, a CLI subcommand). For a typed value, get::<T> deserializes from the underlying JSON; on a missing key or wrong type, you get None and no panic.

Writing the session

session_mut takes a closure that receives &mut SessionData:

use suprnova::session::session_mut;

session_mut(|s| {
    s.put("locale", "en");
    s.put("preferences", serde_json::json!({
        "theme": "dark",
        "notifications": true,
    }));
    s.forget("legacy_key");
});

The closure is sync - guards on the underlying lock drop before any .await, so this composes inside async handlers without holding the lock across suspensions. Anything you serialize must implement Serialize; deserialization on get requires DeserializeOwned.

The closure form (rather than returning a guard) is deliberate. Futures in Tokio can resume on a different worker thread than the one they started on, so the session has to live in a task_local! slot and be borrowed through a scope-bound critical section. The |s| shape makes that boundary explicit and stops you accidentally holding a mutex guard across an .await.

When the closure panics

A panic in a session_mut closure can leave the session between two of its writes. Most panics end the request, and nothing else happens. Some boundaries below the middleware catch the panic and let the request go on: a Live action, or a listener that runs inside the request.

In that case session() and session_mut() keep working, so the code that answers for the panic can still read the session. The middleware does not store the session. It answers 500, keeps the stored session as it was before the request, and logs an error. Cookies that the request queued are still sent with the 500.

Keep the closure free of code that can panic, and do the fallible work before you call session_mut.

Flash data

Flash values are visible for one subsequent request, then disappear. The usual pattern: a controller writes a flash, returns a redirect, the next page renders the flash.

use suprnova::session::session_mut;

session_mut(|s| s.flash("status", "Profile updated."));

On the next request:

use suprnova::session::session_mut;

let status: Option<String> = session_mut(|s| s.get_flash("status"));

get_flash removes the value as it returns it. For the read-without- consume variant use get::<String>("_flash.old.status"), but the consuming form is what controllers usually want.

The full flash surface from Laravel is available:

  • flash(key, value) - write for next request
  • now(key, value) - write for the current request only
  • reflash() - re-flash everything currently visible for one more turn
  • keep(&["k1", "k2"]) - re-flash a specific subset
  • flash_input(map) / old_input() / get_old_input(key) - the form-input bag used by Redirect::with_input / old() helpers

Regenerate and invalidate

After a credential change (login, password reset, 2FA pass) you rotate the session id so a fixated id from before the change is no longer valid:

use suprnova::session::{regenerate_session_id, regenerate_csrf_token};

regenerate_session_id();        // new id, same data
regenerate_csrf_token();        // new CSRF token, same id and data

To clear the session entirely (logout):

use suprnova::session::invalidate_session;

invalidate_session();           // clears data + mints fresh CSRF token

For a security event that needs to revoke every session for a user (password reset elsewhere, account recovery, admin force-logout):

use suprnova::session::destroy_all_for_user;

let rows = destroy_all_for_user("user-42").await?;
tracing::info!(revoked = rows, "all sessions destroyed");

destroy_all_for_user resolves the SessionStore registered by SessionMiddleware::new or with_store and calls destroy_for_user on that configured store. It falls back to a fresh DatabaseSessionDriver only when no session store was registered, such as in a test or embedder that never constructed the middleware.

Authentication helpers

auth_user_id() returns the currently-authenticated user id (consulting request-scoped auth state first, falling back to the persisted session field):

use suprnova::session::{auth_user_id, is_authenticated};

if is_authenticated() {
    let uid = auth_user_id().expect("just checked");
    // ...
}

You normally drive auth through the Auth facade - Auth::login, Auth::logout, Auth::user(). The session helpers are the low-level layer those facades sit on; reach for them when you need to inspect the raw session or when implementing your own guard.

Other operations

The SessionData API mirrors Laravel's Store surface:

Method What it does
get::<T>(key) typed read
put(key, value) typed write
forget(key) remove a single key
forget_many(&[..]) remove many keys
flush() clear all data (keeps id)
has(key) / missing(key) presence check
has_any(&[..]) / has_all(&[..]) bulk presence
all() borrow the underlying map
only(&[..]) / except(&[..]) filtered clones
pull::<T>(key) get-and-forget in one shot
push(key, value) append to an array value
increment(key, n) / decrement(key, n) integer counters
remember::<T>(key, || default()) get-or-compute-and-put
replace(&[(k, v), ..]) flush then bulk put
put_many(&[(k, v), ..]) merge bulk put
previous_url() / set_previous_url(url) what Redirect::back reads
password_confirmed() / password_confirmed_at() "user confirmed password just now" timestamp

Reach for these inside session_mut for mutating ops, session() for reads. The previous_url slot is populated automatically by the middleware on successful GET HTML responses, so redirect()->back() works without you doing anything. The middleware only records a root-relative, same-origin URL: a request path that starts with // or /\ (both read as protocol-relative by a browser) or that carries an ASCII control byte anywhere in it (a TAB or newline lets a value that only looks root-relative turn into one of those two forms once a browser's URL parser strips it) is never stored. previous_url() re-checks the same rule on every read too, so a value written by an older release, before that write-time guard existed, reads back as absent instead of being trusted. Either way, Redirect::back(), Redirect::refresh(), and url::previous() can never resolve to a Location outside your app from a value this slot held.

Configuration

Configure sessions via environment variables - SessionConfig::from_env reads them at boot:

# Lifetime in minutes. Drives both the row TTL and the cookie Max-Age.
SESSION_LIFETIME=120

# Minimum seconds between sliding-expiry writes (default 5 minutes).
# Runtime enforcement caps this below the session lifetime.
SESSION_TOUCH_INTERVAL=300

# Supervised expired-row collection cadence in seconds (default 1 hour).
SESSION_GC_INTERVAL=3600

# Cookie name on the client.
SESSION_COOKIE=suprnova_session

# Cookie attributes
SESSION_SECURE=true          # require HTTPS; DEFAULT IS true
SESSION_PATH=/
SESSION_DOMAIN=.example.com  # optional; unset = host-only
SESSION_SAME_SITE=Lax        # Lax | Strict | None
SESSION_COOKIE_PREFIX=       # empty | __Secure- | __Host-
SESSION_PARTITIONED=false    # CHIPS opt-in
SESSION_EXPIRE_ON_CLOSE=false # true → omit Max-Age, browser drops on close

# Named DB connection for the session store (optional)
SESSION_CONNECTION=sessions

# Remember-me token/cookie lifetime in minutes (default 30 days)
REMEMBER_LIFETIME=43200

A few defaults worth flagging:

  • SESSION_SECURE defaults to true. Sessions sent over plain HTTP would be a credential-leak hazard, so the secure flag is on by default. For local development over HTTP, set SESSION_SECURE=false in your local .env.
  • HttpOnly is always on. There is no knob to disable it - exposing the session cookie to JavaScript forfeits the primary XSS protection and there is no legitimate modern reason to want it.
  • SameSite defaults to Lax. Strict blocks the session on most cross-site GET navigations (including back-links from email); Lax is the usual right answer.

SESSION_COOKIE_PREFIX=__Host- makes the browser host-lock the session and remember-me cookies. A __Host- cookie must be Secure, use Path=/, and omit Domain; a __Secure- cookie must be Secure. Suprnova enforces these rules at render time from the final cookie name, so builder order and queued cookies receive the same protection.

Config::init validates the prefix, SESSION_DOMAIN, and SESSION_PATH at boot and fails before serving when the combination is invalid. Render-time enforcement still forces Secure for either prefix and rewrites a __Host- path to /; it drops a Domain on __Host- and logs a warning because that narrows the requested scope. The browser silently drops an invalid prefixed cookie, so check the boot diagnostic before deployment.

For local HTTP development, leave the prefix empty and set SESSION_SECURE=false only in the local environment. For production, deploy HTTPS, keep SESSION_SECURE=true, use SESSION_COOKIE_PREFIX=__Host-, keep SESSION_PATH=/, and leave SESSION_DOMAIN unset.

Deployment checklist:

  1. Confirm the public origin is HTTPS, including health checks and the first redirect.
  2. Set SESSION_COOKIE_PREFIX=__Host-, SESSION_SECURE=true, and SESSION_PATH=/.
  3. Remove SESSION_DOMAIN; the boot validator rejects it with __Host-.
  4. Inspect the first Set-Cookie response for __Host-suprnova_session, Secure, and Path=/, with no Domain.

Why Suprnova diverges

Laravel does not expose a first-class cookie-prefix knob in its session configuration. Suprnova makes the prefix a configuration value with boot validation because the failure mode is browser-silent: an invalid cookie is discarded before application code can report a session failure. For programmatic config use the fluent builder:

use std::time::Duration;
use suprnova::SessionConfig;

let config = SessionConfig::new()
    .lifetime(Duration::from_secs(60 * 60))      // 1 hour
    .touch_interval(Duration::from_secs(5 * 60))
    .gc_interval(Duration::from_secs(60 * 60))
    .cookie_name("myapp_session")
    .secure(true)
    .domain(".example.com")
    .remember_lifetime(Duration::from_secs(30 * 24 * 60 * 60));

SessionConfig is #[non_exhaustive]; use a default and assign the public field when programmatic configuration needs a prefix:

use suprnova::{CookiePrefix, SessionConfig};

let mut config = SessionConfig::default();
config.cookie_prefix = CookiePrefix::Host;

Wiring it up

SessionMiddleware is installed as a global middleware in your app's bootstrap. The middleware ordering matters: session must come before CSRF, since CSRF reads the per-session token.

use std::sync::Arc;
use suprnova::{global_middleware, CsrfMiddleware, SessionConfig, SessionMiddleware};

pub async fn bootstrap() {
    let config = SessionConfig::from_env();

    // `install` registers the configured GC supervisor as well.
    // Use `SessionMiddleware::new(config)` if you'd rather schedule GC
    // yourself via `Schedule`.
    global_middleware!(SessionMiddleware::install(config).await);

    global_middleware!(CsrfMiddleware::new());
}

SessionMiddleware::install registers a supervised gc task that calls gc() at SESSION_GC_INTERVAL (once an hour by default). The variant install_with_gc(config, interval).await takes a custom interval; new(config) skips the gc task (useful if you'd rather call gc() from a Schedule entry). The supervised task participates in the framework's shutdown drain, so the gc loop exits cleanly on Ctrl-C / SIGTERM instead of being force-aborted.

Protected operations endpoints can expose collector state without querying the sessions table:

use suprnova::session::session_gc_metrics;

let metrics = session_gc_metrics();
tracing::info!(
    runs = metrics.runs,
    failures = metrics.failures,
    removed_rows = metrics.removed_rows,
    last_success = metrics.last_success_unix_seconds,
    "session collector status"
);

To use a non-database store - for tests, or for a Redis-backed driver you write yourself - implement SessionStore and pass it via with_store:

use std::sync::Arc;
use suprnova::{SessionConfig, SessionMiddleware, SessionStore};

let store: Arc<dyn SessionStore> = Arc::new(MyRedisStore::new());
let mw = SessionMiddleware::with_store(SessionConfig::from_env(), store);

Session blocking

Two requests that carry one session cookie load the same row, and without coordination the one that writes last wins: a flash the first request set can be gone before any later request reads it. That happens whenever a page fires several requests at once, such as Live islands connecting while a redirect lands. Session blocking serializes them. With it enabled, the session middleware takes a lock for the session id through the cache lock driver before it loads the session, holds it through your handler and the write, and releases it after, so the second request loads what the first one persisted.

It is off by default. Enable it for the whole application from the environment or in code, or for one route or group with block_session:

SESSION_BLOCK=true
SESSION_BLOCK_LOCK_SECONDS=10   # how long one request holds the lock
SESSION_BLOCK_WAIT_SECONDS=10   # how long a request waits to take it
use std::time::Duration;
use suprnova::{global_middleware, Router, SessionBlock, SessionConfig, SessionMiddleware};

let config = SessionConfig::from_env().block(SessionBlock::default());
global_middleware!(SessionMiddleware::install(config).await);

// One route holds the lock for a minute; the rest of the app keeps the default.
let router: Router = Router::new()
    .post("/orders", place_order)
    .block_session(SessionBlock::new(Duration::from_secs(60), Duration::from_secs(10)))
    .into();

Both bounds are deliberate. The hold is the lock's TTL: a handler that runs past it loses the lock, later requests stop queueing behind it, and the middleware logs a warning so you raise the bound. The wait is how long a request queues before it answers 503 Service Unavailable with Retry-After: 1 instead of holding a connection open. A route-level block takes precedence over the global one. A request without a session cookie names no row two requests could race over, so it never waits and never touches the cache. Blocking uses the cache store the framework boots from CACHE_DRIVER: the in-memory driver serializes requests within one process, and a deployment with several nodes needs Redis for the lock to hold across them.

Why Suprnova diverges

Laravel's block() throws a LockTimeoutException when the wait runs out, which surfaces as a 500. Suprnova answers 503 with Retry-After, because a request that lost the race for its own session is a transient condition the client can retry, not a fault in the server.

The sessions table

The default driver expects a sessions table with this shape (the SeaORM entity in framework/src/session/driver/database.rs is the source of truth):

Column Type Notes
id VARCHAR PK 40-char lowercase alphanumeric session id
user_id VARCHAR NULL authenticated user id (string, supports opaque ids)
payload TEXT JSON-serialized session data map
csrf_token VARCHAR per-session CSRF token
last_activity TIMESTAMP last access; drives expiry + GC

Two indexes ship alongside the table: idx_sessions_user_id (for destroy_for_user) and idx_sessions_last_activity (for gc()).

A scaffolded app includes a create_sessions_table migration that matches this shape. If you bring your own migrations, mirror the column names exactly - SeaORM resolves them positionally and a renamed column won't match.

Why Suprnova diverges

Two places where Laravel made a PHP-shaped choice that Tokio lets us make differently:

Garbage collection. Laravel runs a 2/100 lottery on every request: each request has a 2% chance of triggering session GC inline. It works on PHP because every request spawns a fresh process anyway. On Tokio we have long-lived workers, so SessionMiddleware::install registers one supervised task that calls gc() on a fixed interval. No per-request overhead, no probabilistic surprise - explicit scheduling instead of a lottery, and the supervisor restart loop catches panics so a single bad gc doesn't kill the daemon.

Closure-form session_mut. Laravel hands you $request->session() and lets you call methods on it. We don't, because handlers in Suprnova are futures and a future can resume on a different worker thread than it started on. The session lives in a Tokio task_local! slot, which means borrowed access has to happen inside a scope. The closure form makes that scope explicit and statically prevents the mistake of holding a mutex guard across .await.

Fail-closed on dirty writes. A failed bounded activity touch logs warn! and lets the request through with its existing cookie (the user-visible state is intact). A failed write of a modified session - login, flash, CSRF rotation - returns 500. Silently handing the client a cookie for state the store never recorded would make a "successful" login vanish on the very next request; better to surface the failure loudly.

Next

  • Authentication - Auth::login, guards, the user provider chain
  • Auth Flows - password reset, 2FA, brute-force throttling, remember-me
  • CSRF - how the session's CSRF token gets checked on writes
  • Middleware - writing your own middleware that reads or writes the session
  • Request Lifecycle - where SessionMiddleware sits in the chain