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, bound to the cookie name). Tampered, undecryptable, or malformed cookies are treated as absent, and so is a value that another cookie wrote. - 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.
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 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!;
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 ;
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. 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_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.
Cookie-name prefix hardening
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:
- Confirm the public origin is HTTPS, including health checks and the first redirect.
- Set
SESSION_COOKIE_PREFIX=__Host-,SESSION_SECURE=true, andSESSION_PATH=/. - Remove
SESSION_DOMAIN; the boot validator rejects it with__Host-. - Inspect the first
Set-Cookieresponse for__Host-suprnova_session,Secure, andPath=/, with noDomain.
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 Duration;
use SessionConfig;
let config = new
.lifetime // 1 hour
.touch_interval
.gc_interval
.cookie_name
.secure
.domain
.remember_lifetime;
SessionConfig is #[non_exhaustive]; use a default and assign the public
field when programmatic configuration needs a prefix:
use ;
let mut config = default;
config.cookie_prefix = 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 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;
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 Duration;
use ;
let config = from_env.block;
global_middleware!;
// One route holds the lock for a minute; the rest of the app keeps the default.
let router: Router = new
.post
.block_session
.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
SessionMiddlewaresits in the chain
