Skip to content

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

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

@override
Future<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,b requires every named ability.
  • ability:a,b requires 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:

Terminal window
maat cartouche:prune-expired --hours=24

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