Skip to content

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

Include 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:

@override
Future<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']);
  1. anubis.session opens the session for this request (from the cookie, or a fresh one) and attaches it to request.session. Everything after it depends on that attribute existing — reading request.session before this middleware has run throws a StateError.
  2. anubis.csrf rejects a state-changing request (anything but GET, HEAD, or OPTIONS) whose presented token does not match request.session.csrfToken. It reads the session that anubis.session attached, so it must come after it.
  3. auth:web resolves request.user<T>() from the identity the session carries. It also reads request.session, so it too must come after anubis.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-TOKEN request header, or
  • a _token field 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:

Terminal window
maat anubis:prune-sessions
Terminal window
maat 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.