Authentication
Protect the route with a guard, then read the authenticated user from the request:
Route.get('/profile', (Request request) { final user = request.requireUser<User>(); return {'id': user.id, 'email': user.email};}).middleware(['auth:cartouche']);Maat’s core package defines authentication contracts, password hashing,
guards, and request access. It does not define a User model or depend on the
ORM: the columns that identify a user belong to the application.
Scaffolding Authentication
Rather than hand-writing a User model, a controller, and two forms, scaffold
session authentication in one command:
maat make:authEight files, none of them overwriting anything you already have:
| File | What it is |
|---|---|
database/migrations/create_users_table.dart |
id, name, email (unique), password, timestamps |
lib/app/models/user.dart |
A Seshat model implementing Authenticatable; toJson never carries the password hash |
lib/app/http/controllers/auth_controller.dart |
Register, login, logout |
lib/app/http/controllers/profile_controller.dart |
Account settings: change name/email, change password, sign out |
routes/auth.dart |
authRoutes() — every route inside anubis.session, every state-changing one additionally inside anubis.csrf |
resources/views/auth/login.khnum.html |
The sign-in form |
resources/views/auth/register.khnum.html |
The sign-up form |
resources/views/auth/settings.khnum.html |
Change name/email and change password, as two independent forms |
Then add the four lines it prints — the provider registration, the session
migrations and your users migration, authRoutes(), and the user
provider — and run maat migrate:
// bootstrap/app.dart, in withProviders([...])AnubisServiceProvider.new,
...anubisMigrations, // creates anubis_sessionsCreateUsersTable(),
// routes/web.dartauthRoutes();
// lib/app/providers/app_service_provider.dart, in boot()Anubis.provider('users', (id) => User.query().find(int.tryParse(id) ?? -1));...anubisMigrations is not optional. anubis.session — the middleware
every generated route runs behind — opens a database session on every
request, and there is no non-database session driver to fall back to.
Skip that line and the login page you just generated 500s on its first
request. make:auth reads your database/migrations.dart and prints a
specific warning if it looks like this line is missing, rather than only
naming it in the same generic list as the other three.
It edits nothing. bootstrap/app.dart, routes/web.dart, and
database/migrations.dart are yours; the command prints what to add rather
than rewriting them. Running it twice skips every file that already exists;
--force overwrites all eight.
It needs the starter kit views. An application created with --api has
no resources/, so make:auth refuses rather than writing files that
cannot render.
What it deliberately does not do
No password reset, no email verification, no remember-me, no two-factor authentication, no account deletion.
An email change takes effect immediately, with no re-verification step.
There is no email verification system in this framework to hook a
confirmation into, so /settings/profile writes the new address as soon as
it validates.
A password change does invalidate the account’s other sessions. Every
anubis_sessions row for that user except the one making the request is
deleted the moment the new password is saved — changing your password
signs out every other device, not just protects future logins.
No rate limiting on /login. It is the one route make:auth generates that
a brute-force attempt targets directly; add throttle yourself if the
application is public:
Route.post('/login', auth.login).middleware(['throttle:5,1']);The Authenticatable Contract
Implement Authenticatable on any object a guard may return:
class User extends Model<User> implements Authenticatable { // Model definition and fields omitted.
@override Object get authIdentifier => id!;
@override String get authPassword => password;}authIdentifier is the value stored by token and session systems.
authPassword is the encoded password, or an empty string for an account that
cannot sign in with a password.
Password Hashing
final encoded = await Hash.makeAsync('correct horse battery staple');
if (await Hash.checkAsync(input, encoded)) { // Authenticated.}
if (Hash.needsRehash(encoded)) { // Replace it after a successful login.}Store only encoded, never the plaintext password. Treat both the submitted
password and the encoded hash as sensitive values that must not enter logs.
Use the asynchronous methods in request handlers. Argon2 is intentionally
expensive; makeAsync and checkAsync move that work to another isolate so
the HTTP isolate can keep serving requests. The synchronous make and check
methods remain useful in scripts and controlled tests.
Passwords use Argon2id because human-chosen input has low entropy and must be expensive to guess. Personal access tokens use SHA-256 instead: their random 40-character secret already has enough entropy, so making every authenticated request deliberately slow would add cost without useful protection.
Configure new hashes in config/hashing.dart:
Map<String, dynamic> get hashing => { 'argon': {'memory': 19456, 'time': 2, 'threads': 1},};The parameters used to verify an existing password come from its encoded PHC
string. Raising the configuration therefore does not invalidate old hashes;
needsRehash tells you when to upgrade one after a successful login.
check/checkAsync return false for a malformed hash rather than
throwing; needsRehash takes the opposite view of the same input and
returns true, since an unparseable hash needs replacing regardless.
Guards
A guard resolves the user for one request:
class HeaderGuard implements Guard { @override Future<Authenticatable?> user(Request request) async { final id = int.tryParse(request.header('x-user-id') ?? ''); return id == null ? null : User.query().find(id); }}Register guards from a service provider:
@overridevoid register() { Auth.extend('header', HeaderGuard.new);}The core package ships no concrete guard. cartouche registers cartouche,
and anubis registers web through the same contract.
Protecting Routes
Register the parameterized middleware alias in bootstrap/app.dart:
.withMiddleware((middleware) => middleware.alias({ 'auth': Authenticate.factory,}))Then name the guard on a route:
Route.get('/profile', profile).middleware(['auth:cartouche']);With no parameter, auth reads the default from config/auth.dart:
import 'package:maat/maat.dart';
Map<String, dynamic> get auth => { 'defaults': {'guard': env('AUTH_GUARD', 'cartouche')},};An unauthenticated request receives 401. An unknown guard throws an
ArgumentError instead of silently leaving the route unprotected.
Reading the User
Future<Object?> profile(Request request) async { final user = request.requireUser<User>(); return user;}request.user<User>() returns null when no user was authenticated.
requireUser<User>() throws a 401 and is convenient behind auth middleware.
Both check the requested type; asking for the wrong model type produces an
error naming the mismatch rather than a misleading null.
Failure Responses
When a guard returns null, authentication stops before the handler and the
client receives 401 Unauthenticated. An unknown guard name throws an
ArgumentError; a typo such as auth:cartochue must fail loudly rather than
silently expose the route.
Authorization is a separate step. Once a user is authenticated, middleware or
application policy should still decide whether that user may perform the
requested action and return 403 when they may not.
Testing Authentication
Test the route boundary, not just the guard in isolation:
final guest = await TestClient(app).get('/profile');guest.assertStatus(401);
final member = await TestClient(app) .withToken(token) .get('/profile');member.assertOk();For Cartouche tests that do not need token persistence, use
Cartouche.actingAs(user) and reset its static state in tearDown. See
Cartouche API Tokens.