Anubis Web Sessions
Attach a session, protect the form, and read who is signed in:
Route.get('/login', loginForm).middleware(['anubis.session', 'anubis.csrf']);Route.post('/login', login).middleware(['anubis.session', 'anubis.csrf']);
Route.get('/dashboard', dashboard) .middleware(['anubis.session', 'anubis.csrf', 'auth:web']);Future<Response> dashboard(Request request) async { final user = request.requireUser<User>(); return Response.text('Welcome, ${user.name}');}anubis provides database-backed sessions, CSRF protection, and a web
guard for browser-facing Maat applications. Where cartouche issues bearer
tokens for mobile apps and API clients, anubis is for server-rendered pages
and form posts that authenticate through a cookie.
The Security Model
The cookie handed to the browser carries a random 32-byte token. The
database only ever stores that token’s SHA-256 digest, as the id column of
anubis_sessions. A dump of the table cannot be replayed as a cookie: an
attacker who reads the database gets digests, not the secrets that produce
them.
The CSRF token is not covered by that scheme. It lives in the session’s
payload column as plain JSON, so a database dump exposes every live CSRF
token in cleartext. That is acceptable because a CSRF token is useless on its
own: presenting one only passes anubis.csrf on a request that already
carries the matching session cookie, and the cookie is exactly what the
digest on id keeps an attacker from reconstructing.
Installation
maat make:auth scaffolds a User model, login/registration/account
controllers, routes, and views in one command — see
Scaffolding Authentication.
This section documents wiring Anubis in by hand, which is also what that
generated code needs added to it.
Add the package:
dependencies: anubis: ^0.1.0Include its migration before migrations that depend on users:
import 'package:anubis/anubis.dart';
final migrations = <Migration>[ ...anubisMigrations, CreateUsersTable(),];Register the provider in bootstrap/app.dart:
final app = await Application.configure(basePath: Directory.current.path) .withProviders([ DatabaseServiceProvider.new, AnubisServiceProvider.new, ]) .create();Registering AnubisServiceProvider is enough on its own: it aliases
anubis.session, anubis.csrf, and auth for you, and registers the web
guard. There is nothing further to add through .withMiddleware.
Your user model implements Authenticatable (see
Authentication):
class User extends Model<User> implements Authenticatable { @override Object get authIdentifier => id!;
@override String get authPassword => password;}Then teach Anubis how to load that model, in a service provider’s boot:
@overrideFuture<void> boot() async { Anubis.provider('users', (id) => User.query().find(int.parse(id)));}A session stores authIdentifier as a string, so id here always arrives
as a String; parsing it back into whatever type your primary key actually
is — an int above — is the provider’s job. Registering the same alias
twice is a wiring bug and throws.
Configuration
config/anubis.dart controls the session lifetime and cookie attributes:
import 'package:maat/maat.dart';
Map<String, dynamic> get anubis => { 'lifetime': envInt('SESSION_LIFETIME', 120), 'cookie': env('SESSION_COOKIE', 'maat_session'), 'path': env('SESSION_PATH', '/'), 'domain': env('SESSION_DOMAIN'), 'secure': envBool('SESSION_SECURE', false),};| Key | Default | Notes |
|---|---|---|
anubis.lifetime |
120 |
Minutes of inactivity before a session is treated as expired and replaced with a fresh one. |
anubis.cookie |
maat_session |
The cookie name. |
anubis.path |
/ |
The cookie’s Path attribute. |
anubis.domain |
unset | The cookie’s Domain attribute; omitted entirely when unset. |
anubis.secure |
false |
Adds Secure on top of what the application forces automatically (below). |
A response carries exactly one Set-Cookie header, because Response.headers
is a Map<String, String> — Anubis is the framework’s first cookie writer, so
a handler that sets its own cookie will have it replaced by the session
cookie.
Every cookie carries HttpOnly and SameSite=Lax unconditionally, and
Secure is forced on whenever config('app.url') is an https:// URL —
anubis.secure can only add Secure on top of that, never remove it. A
configured cookie name or path that is empty, or that contains whitespace,
;, ,, or =, is rejected rather than sanitized: those characters could
break out of the Set-Cookie header, and a config typo should fail loudly
instead of quietly producing a malformed cookie. The domain is the one
exception: an empty SESSION_DOMAIN is treated the same as an absent one —
no Domain attribute is emitted — since leaving it unset is the normal
case; a non-empty domain is still rejected the same way a hostile name or
path is.
Protecting Routes: Required Order
Sessions, CSRF protection, and the web guard depend on each other in a
fixed order:
Route.post('/posts', store) .middleware(['anubis.session', 'anubis.csrf', 'auth:web']);anubis.sessionopens the session for this request (from the cookie, or a fresh one) and attaches it torequest.session. Everything after it depends on that attribute existing — readingrequest.sessionbefore this middleware has run throws aStateError.anubis.csrfrejects a state-changing request (anything butGET,HEAD, orOPTIONS) whose presented token does not matchrequest.session.csrfToken. It reads the session thatanubis.sessionattached, so it must come after it.auth:webresolvesrequest.user<T>()from the identity the session carries. It also readsrequest.session, so it too must come afteranubis.session.
A GET-only route that only needs the current user can drop anubis.csrf
(safe methods never require a token), but it still needs anubis.session
first — the web guard cannot run without it.
This order is load-bearing, not a style preference. AnubisGuard caches
the user it resolves under the request attribute anubis:user, which is a
different key from auth:user / auth:resolved — the pair that only the
auth middleware writes. If a controller calls
Auth.guard('web').user(request) directly instead of going through
auth:web, it gets a correctly resolved user back, but
request.user<T>() still returns null, because nothing wrote
auth:user. There is no error, no warning — just a route that looks
protected in code review and lets every request through. Always route
through the auth:web middleware rather than calling the guard directly, and
keep this three-step order on every route that needs it.
Login and Logout
// A controller field, started once rather than recomputed per request.final Future<String> _dummyHash = Hash.makeAsync('invalid-credentials');
Future<Response> login(Request request) async { final email = request.string('email') ?? ''; final password = request.string('password') ?? ''; final user = await User.query().where('email', email).first(); final valid = await Hash.checkAsync( password, user?.authPassword ?? await _dummyHash, ); if (user == null || !valid) { throw ValidationException({ 'email': ['These credentials do not match our records.'], }); } await Anubis.login(request, user, as: 'users'); return Response.redirect('/dashboard');}As with cartouche (see
Issuing Tokens), always perform one valid
hash check and return the same response whether the email exists or not —
otherwise the login route becomes a user-enumeration oracle. Rate-limit it
too: Route.post('/login', login).middleware(['throttle:5,1']).
as must name a provider already registered with Anubis.provider.
login rotates the session to a new id and a new CSRF token before writing
the identity — the same rotation regenerate performs below — which
defeats session fixation: a token an attacker handed the victim before
login is worthless afterward. A login call that fails (an unregistered
provider, or an authIdentifier that is neither String nor int) throws
before anything is written, so a rejected login never leaves a half-rotated
session behind.
await Anubis.logout(request);logout invalidates the session outright: the database row is deleted and
the cookie is expired when the response is sent. This is deliberate — a
logout that only cleared the identity would leave the pre-logout CSRF token
and any other session data still usable by whoever holds the cookie.
Inside a handler, read the signed-in user the same way cartouche routes
do:
final user = request.requireUser<User>();CSRF Protection
Every state-changing request must present the session’s token back, in one of two places:
- the
X-CSRF-TOKENrequest header, or - a
_tokenfield in the request body.
Never the query string. request.string('_token') — which
anubis.csrf uses to read the form field — also reads query parameters, so
POST /posts?_token=<token> succeeds if the token happens to be correct.
This is not a bypass: an attacker still cannot produce a valid token without
already holding the session cookie. But a token that travels in a URL ends
up in access logs, browser history, and any outbound Referer header, which
a token in a header or POST body does not. Put _token in the request body
or the X-CSRF-TOKEN header — never append it to a URL, even for a route
you also expect to be hit as a GET.
Read the current token to embed in a form or send as a header:
final token = request.session.csrfToken;Maat has no view-template CSRF helper yet, so pass token into your view’s
data and render it as a hidden input yourself:
<input type="hidden" name="_token" value="{{ token }}">A missing or mismatched token gets a 419 response. The failure message
deliberately says nothing about either token’s value — a 419 body is a
place a secret could leak into logs or a bug report.
Flash Data
Flash values survive exactly one request past the one that wrote them — useful for a redirect-after-post status message:
request.session.flash('status', 'Post created.');return Response.redirect('/posts');final status = request.session.flashed('status');A value is not readable through flashed on the same request that called
flash — only on the next one. Re-flashing a value you just read
(session.flash('status', session.flashed('status'))) keeps it alive for one
more request.
Every session value — flashed or not — must be JSON-encodable: put accepts
Object?, but a value dart:convert cannot encode throws when the session is
written, after your handler has already returned, surfacing as a 500 with
nothing pointing back at the put that caused it.
Flash written around logout is silently dropped. Anubis.logout invalidates
the session, which clears the payload, and an invalidated session’s payload is
never written — so session.flash('status', 'Signed out.') produces nothing on
the next page whether you call it before or after the logout. Put a
post-logout message in the redirect target instead.
Regenerating the Session
Anubis.login calls WebSession.regenerate for you, but you can call it
directly for any other privilege boundary — after a password change, say:
request.session.regenerate();It mints a new session id and rotates the CSRF token together: a token
captured before the boundary must not still work after it. regenerate
accepts an optional tokenFactory override:
request.session.regenerate(tokenFactory: someOtherGenerator);That override only replaces the generator used for the session token —
the CSRF token rotation that follows always uses the session’s own factory,
never the override. Every caller in this package passes the same generator
for both, so this distinction is invisible today. It only matters if you
ever pass a tokenFactory that genuinely differs from the session’s own —
in that case, expect the CSRF token to still come from the original
generator.
Pruning Expired Sessions
Register ...anubisCommands() in the console kernel, then remove rows
whose last_activity is older than the configured lifetime from a
scheduler or cron job:
maat anubis:prune-sessionsmaat anubis:prune-sessions --lifetime=60--lifetime (in minutes) overrides anubis.lifetime for that run. The
command deletes only sessions past the cutoff; live sessions are untouched.
Testing
anubis ships no actingAs-style helper — TestClient carries no cookie
jar, so drive a session the same way the package’s own tests do: open one
directly through SessionManager (after setting Config.current and a
database, as its tests do in setUp), or attach the cookie a previous
response returned.
final manager = SessionManager(now: () => DateTime.utc(2026, 9, 3, 10));final session = await manager.open(Request.create());session.userType = 'users';session.userId = '${user.id}';await manager.persist(session, Request.create(), Response.text('ok'));Against a running application, read the cookie back off a login response and replay it:
final login = await TestClient(app).post( '/login', form: {'email': 'user@example.com', 'password': 'secret'},);final cookie = login.headers['set-cookie']!.split(';').first;
final page = await TestClient( app,).withHeader('cookie', cookie).get('/dashboard');page.assertOk();Reset Config.current and close any in-memory database connection you
opened in tearDown, as the package’s own tests do.
Current Scope
Anubis provides one database session driver — no cookie-only, file, or
Redis session store — and one web guard. “Remember me” persistent login
and multi-device session listing are not included. Use cartouche for
bearer-token authentication instead of, or alongside, anubis; the two are
independent and can be mixed on the same application by guard name
(auth:web vs auth:cartouche).
Anubis does not lock sessions: concurrent requests for the same session are last-write-wins, and a request that opened the session before another tab logged out will re-create the row when it persists.
A session that was never read from or written to, and that carries no
identity, is not stored at all and gets no cookie — otherwise every crawler
hitting a public route behind anubis.session would leave a row behind.
Reading csrfToken, writing a value, flashing one, or signing a user in all
make the session worth storing.