Cartouche API Tokens
Issue a token once, then send its plaintext value as a bearer token on later requests:
final issued = await user.createToken('Abdullah iPhone', ['posts:read']);return {'token': issued.plainTextToken};Authorization: Bearer <id>|<secret>cartouche provides database-backed personal access tokens for mobile
applications, API clients, and integrations. It follows the personal-token
model familiar from Laravel Sanctum. Cookie-based SPA authentication is not
included yet because it requires sessions and CSRF protection.
Installation
Add the package:
dependencies: cartouche: ^0.1.0Include its migration before migrations that depend on users:
import 'package:cartouche/cartouche.dart';
final migrations = <Migration>[ ...cartoucheMigrations, CreateUsersTable(),];Register the provider and middleware aliases in bootstrap/app.dart:
.withProviders([ DatabaseServiceProvider.new, CartoucheServiceProvider.new,]).withMiddleware((middleware) => middleware.alias({ ...cartoucheMiddlewareAliases,}))Your user model implements Authenticatable and mixes in HasCartoucheTokens:
class User extends Model<User> with HasCartoucheTokens<User> implements Authenticatable { @override Object get authIdentifier => id!;
@override String get authPassword => password;
@override String get cartoucheType => 'users';}Finally, teach Cartouche how to load that type in a service provider:
@overrideFuture<void> boot() async { Cartouche.provider('users', (id) => User.query().find(id));}Laravel stores a model class name and resolves it reflectively. Dart has no
runtime class lookup in compiled applications, so Cartouche.provider is the
explicit, AOT-safe replacement. An unregistered type throws with the missing
registration instead of looking like an invalid token.
Issuing Tokens
final issued = await user.createToken( 'Abdullah iPhone', ['posts:read', 'posts:write'],);
return {'token': issued.plainTextToken};The returned value has the form <id>|<40-character-secret>. Return it once;
only its SHA-256 hash is stored and the plaintext cannot be recovered later.
Never log it.
A password exchange can issue the token after validating credentials:
// A controller field, started once rather than recomputed per request.final Future<String> _dummyHash = Hash.makeAsync('invalid-credentials');
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': ['The provided credentials are incorrect.'], });}return {'token': (await user.createToken(deviceName)).plainTextToken};Use the same response and perform one valid hash check whether the email exists or not. Otherwise the endpoint becomes a user-enumeration oracle. Rate-limit the route as well:
Route.post('/tokens', tokens.store).middleware(['throttle:5,1']);Protecting Routes and Abilities
Route.get('/user', profile).middleware(['auth:cartouche']);
Route.post('/posts', store).middleware([ 'auth:cartouche', 'ability:posts:write',]);abilities:a,brequires every named ability.ability:a,brequires at least one named ability.*grants every ability.
Both ability middleware deny requests that were not authenticated by a token. A malformed abilities value grants nothing.
Inside a protected handler:
final user = request.requireUser<User>();final token = request.accessToken!;
if (token.can('posts:write')) { // ...}Listing and Revoking Tokens
final tokens = await user.tokens();await user.revokeToken(tokenId);await user.revokeTokens();To revoke the token making the current request:
await request.accessToken!.delete();return Response.noContent();Revocation takes effect on the next request; authenticated tokens are not cached across requests.
Expiration and Pruning
config/cartouche.dart controls the default lifetime in minutes:
import 'package:maat/maat.dart';
Map<String, dynamic> get cartouche => { 'expiration': envInt('SANCTUM_EXPIRATION', 0),};0 or an absent value means no expiry. An explicit third argument to
createToken overrides the configuration.
Register ...cartoucheCommands() in the console kernel, then remove old expired
tokens from a scheduler or cron job:
maat cartouche:prune-expired --hours=24The command removes only tokens whose expiry is older than the cutoff. Tokens without an expiry are never pruned.
Testing
setUp(() { Cartouche.actingAs(user, abilities: ['posts:write']);});
tearDown(() { Cartouche.reset(); Application.reset();});actingAs supplies an in-memory token and does not touch the database.
Alternatively, issue a real token and pass it through
TestClient(app).withToken(token) to test the complete bearer-token flow.
Current Scope
Cartouche provides personal access tokens only. Cookie-based SPA authentication, browser sessions, and CSRF protection are deliberately outside this release; use token authentication for mobile apps, API clients, and integrations.