Middleware, Sessions & CSRF
Middleware
Middleware is a plain Axum middleware function - .middleware(...) on a
Router attaches it, and it applies to whatever it’s attached to
(.group(...) scopes it to a subset, the whole chain applies it to
everything). There’s no named-middleware registry to register into and
nothing to look up by string:
route.group("", |r: Router| {
r.middleware(axum::middleware::from_fn(require_auth))
.get("/posts/create", PostController::create)
})
Write your own with xr make:middleware EnsureSubscribed, which
generates the standard Axum middleware-function shape
(async fn(request, next) -> Response) for you to fill in.
Sessions
.with_sessions(pool, secure_cookie) attaches session support to a
router - called once, on the final, fully-merged router (not inside a
.group(...) closure, since it’s async and every route-building
closure is not). A generated app’s serve() does this for you already:
route.with_sessions(larust_support::orm::pool()?, app.config().session_secure_cookie).await?
Sessions are backed by tower-sessions,
via one of two real SESSION_DRIVER values:
# .env
SESSION_DRIVER=database # default
# SESSION_DRIVER=file
database(default) - stored in the same databaseDB_CONNECTIONpoints at (a hand-writtenSessionStoreoversqlx::AnyPool, since third-party per-backend store crates need a concretely-typed pool Larust’s runtime-generic pool can’t give them).file- one file per session understorage/sessions/, Laravel’s ownSESSION_DRIVER=fileequivalent. No database round trip for session reads/writes; a real choice worth reaching for if session churn is putting real load on your database (seedocs/GOTCHAS.mdfor a real incident that caused). Only works for a single server: every process reading/writing sessions needs to see the same directory, which a real multi-server deployment behind a load balancer can’t guarantee the way a shared database can - stick withdatabaseif that’s where you’re headed.
Neither is an in-memory store: session data needs to survive a process
restart - a deploy, a crash, xr dev’s own rebuild-and-restart cycle -
which files on disk do exactly as well as database rows do. There’s
deliberately no in-memory option in the public API at all, and never will
be: an in-memory session store that quietly logs every user out on every
deploy is a well-known Laravel footgun (SESSION_DRIVER=array reaching
production) that persisting to something - a database or a file, either
one - avoids entirely.
Any other SESSION_DRIVER value (a typo, or a real Laravel value this
framework doesn’t implement - array, redis, cookie, …) falls back
to database, with a startup warning explaining why - see
Config::session_driver’s own doc comment.
A handler reads/writes the session through the Session extractor -
tower_sessions::Session, re-exported directly:
pub async fn store(session: Session, ...) -> Result<impl IntoResponse, AppError> {
session.insert("user_id", user.id).await?;
let flash: Option<String> = larust_http::session::take(&session, "success").await?;
}
Prefer larust_http::session::take over a bare session.remove(key)
for a flash-message read that runs on every page view (checking “is
there a success/error message to show” on every response, not just the
ones that redirected in with one) - tower_sessions::Session::remove
marks the session modified even when the key was never present, so a bare
.remove() writes to the session store on every request, not just the
ones with something to flash. take checks presence first and only calls
remove when there’s actually something to remove. See docs/GOTCHAS.md
for the real production incident (cascading SQLite lock contention) this
caused before take existed.
The session cookie’s Secure attribute is silently dropped on any
hostname a browser doesn’t recognize as a secure context - only
127.0.0.1, ::1, and the literal localhost qualify over plain HTTP. A
custom local-dev hostname (a .test domain in /etc/hosts, even one that
resolves to loopback) will look fine in the browser but silently receive
no session cookie at all - every state-changing request then fails CSRF
verification with no error pointing at the real cause. Set
SESSION_SECURE_COOKIE=false in .env if you develop against a custom
hostname rather than localhost.
CSRF
larust_http::csrf::verify is the middleware; a generated app’s
routes/web.rs applies it once, to the whole router, at the very end of
the chain - and deliberately not to routes/api.rs, since CSRF
protects cookie-authenticated browser form submissions, and an API route
merged in via .merge() is immune to whatever middleware the router it’s
merged into carries (see Routing).
In a template, @csrf expands to a hidden input carrying the current
session’s token:
<form method="post" action="/posts">
@csrf
<input name="title">
</form>
Equivalently, for a JS-driven request (a fetch() call, a file upload)
where a hidden form field isn’t natural, send the token as a header
instead - checked before the body, matching Laravel’s own convention:
fetch("/posts", { method: "POST", headers: { "X-CSRF-TOKEN": token } })
Get the current token explicitly (e.g. to embed in a <meta> tag for
JS to read, as the reference layout does) with:
let csrf_token = larust_http::csrf::token(&session).await;
A request that fails verification gets a real 419 page with a link
home, not a bare “CSRF token mismatch” string - xr dev’s own auto-reload
used to be able to trigger this spuriously by resubmitting a stale POST
on reconnect; that’s fixed, but the page itself stayed friendlier since a
genuine mismatch (an expired tab, a forged request) can still reach it.
Shared response caches
larust_http::responsecache::for_minutes(...) is a URL-keyed, shared cache.
Use it only for a response that is exactly the same for every visitor.
Larust refuses to store responses that set a cookie or explicitly declare
Cache-Control: private, no-cache, or no-store, but that safety check
cannot identify every personalized response. For content that intentionally
varies by session, use for_minutes_per_session(...); when the scope is not
clear, leave the response uncached.
Next
Error Handling covers what happens when any of this
fails - AppError, APP_DEBUG, and the default error pages.