Manual contentsThe BasicsBrowse 103 chapters
Manual 10 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). Tampered, undecryptable, or malformed cookies are treated as absent.
  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.

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");

This wraps SessionStore::destroy_for_user against the framework's default DatabaseSessionDriver. If you bound a custom store, call destroy_for_user on it directly.

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.

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_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.

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));

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);

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