Skip to content

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:

Terminal window
maat make:auth

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

database/migrations.dart
// bootstrap/app.dart, in withProviders([...])
AnubisServiceProvider.new,
...anubisMigrations, // creates anubis_sessions
CreateUsersTable(),
// routes/web.dart
authRoutes();
// 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:

@override
void 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.