Sanctum checks the token on every request. That costs a query for the token, a query for its consumer, and a write to last_used_at. It also has a consequence people underrate: revocation is immediate.
$consumer->tokens()->where('name', 'production')->delete();
The next request with that token gets a 401. There is no cache to wait out.
I used to cache token lookups to save those queries, and I had to write paragraphs explaining how long a revoked token stays valid. That is the trade. Don’t make it until you have measured that authentication is your bottleneck, and Chapter 9 is about how to measure.
Rotation is issuing a new token before the old one expires, and deleting the old one once the consumer has switched. Because a consumer can hold several tokens, the two overlap and nothing goes down.
Expired tokens stop working on their own, but their rows stay. Schedule Sanctum’s command to clear them:
// routes/console.php
Schedule::command('sanctum:prune-expired')->daily();
And look at last_used_at now and then. A token that hasn’t been used in three months belongs to a service that no longer exists.
Testing It
Sanctum’s test helper authenticates a model with a set of abilities, without creating a real token:
// tests/Feature/AuthTest.php
it('rejects a request without a token', function () {
$this->getJson('/api/licenses')->assertUnauthorized();
});
it('forbids writing with a read-only token', function () {
Sanctum::actingAs(
Consumer::factory()->create(),
['licenses:read'],
);
$this->postJson('/api/licenses', [
'name' => 'Acme',
'domain' => 'acme.test',
])->assertForbidden();
});
Write the second kind for every ability you define. Until a test like it exists, nobody has checked that the ability does anything.
Give actingAs the exact abilities the test is about, never ['*']. A wildcard passes every check, so a test that uses it proves nothing about authorization.
One more test belongs here, because it guards the claim this chapter rests on. It checks that a request is made to the provider with the caller’s credentials and not somebody else’s:
it('calls the provider as the consumer', function () {
Http::fake(['*' => Http::response(['data' => []])]);
$consumer = Consumer::factory()
->forPartner('partner-token')
->create();
Sanctum::actingAs($consumer, ['licenses:read']);
$this->getJson('/api/licenses')->assertOk();
Http::assertSent(fn ($request) => $request->hasHeader(
'Authorization',
'Bearer partner-token',
));
});
forPartner() is a factory state that puts a provider token in settings. Chapter 12 shows it. From here on, tests in this book that call a protected route authenticate first, usually in a beforeEach. That includes Chapter 2’s test, which now needs a caller before it can reach validation.
When Not to Use This
- A browser application of your own. Use Sanctum’s cookie-based SPA authentication, not tokens in local storage.
- Third-party developers who need to act for your users. That is OAuth, and Laravel Passport implements it.
- A public, anonymous API. Skip authentication and lean on the rate limiting in Chapter 6.
Chapter 4 Summary
- Authenticate services with Sanctum API tokens on a model of their own. A tokenable model doesn’t have to be a person.
- Tokens are stored hashed. Always pass abilities and an expiry, and never issue
*. auth:sanctumon the route group.$request->user()is the consumer.- Declare abilities on the controller, closed by default. A new controller is open until you close it.
- Per-tenant credentials fail closed. No shared fallback.
- Anything that depends on who is asking is scoped, not a singleton.
- Don’t cache authentication until you’ve measured it. Immediate revocation is worth the queries.