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:
- Reads the session id and last successful activity-touch timestamp from
the
suprnova_sessioncookie (AES-256-GCM encrypted). Tampered, undecryptable, or malformed cookies are treated as absent. - Loads
SessionDatafrom 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 logswarn!and lets a state-free request continue, but a handler mutation then fails closed rather than overwrite unknown stored state. - 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. - Binds the session into a task-local slot for the duration of the
handler.
session()andsession_mut()look the slot up. - 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 session;
if let Some = session
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 session_mut;
session_mut;
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 session_mut;
session_mut;
On the next request:
use session_mut;
let status: = session_mut;
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 requestnow(key, value)- write for the current request onlyreflash()- re-flash everything currently visible for one more turnkeep(&["k1", "k2"])- re-flash a specific subsetflash_input(map)/old_input()/get_old_input(key)- the form-input bag used byRedirect::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 ;
regenerate_session_id; // new id, same data
regenerate_csrf_token; // new CSRF token, same id and data
To clear the session entirely (logout):
use 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 destroy_all_for_user;
let rows = destroy_all_for_user.await?;
info!;
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 ;
if is_authenticated
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_SECUREdefaults totrue. Sessions sent over plain HTTP would be a credential-leak hazard, so the secure flag is on by default. For local development over HTTP, setSESSION_SECURE=falsein your local.env.HttpOnlyis 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.SameSitedefaults toLax.Strictblocks the session on most cross-site GET navigations (including back-links from email);Laxis the usual right answer.
For programmatic config use the fluent builder:
use Duration;
use SessionConfig;
let config = new
.lifetime // 1 hour
.touch_interval
.gc_interval
.cookie_name
.secure
.domain
.remember_lifetime;
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 Arc;
use ;
pub async
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 session_gc_metrics;
let metrics = session_gc_metrics;
info!;
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 Arc;
use ;
let store: = new;
let mw = with_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
SessionMiddlewaresits in the chain
