If you have already worked through initial Sanctum setup as part of AI deployment operations, you have solved authentication. Every request carries a valid token, and Sanctum confirms the user behind it. What that setup does not solve is authorization at the token level, and that gap becomes a real production concern the moment a token is held by something other than a human clicking buttons in a browser.
Laravel Sanctum token abilities close that gap. An ability is a string scope attached to a token at issuance, checked on every request against the action the request is trying to perform. Instead of one all-or-nothing credential, a single user account can hold multiple tokens, each authorized for a narrower slice of what that account is allowed to do.
Why a Fully-Authenticated Token Is Not the Same as a Safe One
Most Sanctum implementations issue a token with createToken('token-name') and never look at abilities again. That works fine when the token holder is a human who reads the UI, understands the consequences of clicking “delete,” and operates inside your application’s own guardrails. It works far less well when the token holder is an autonomous agent executing tool calls generated by an LLM.
An agent with a full-access token inherits every permission its owning user has, not just the permissions it needs to complete its assigned task. If a prompt injection attack or a malformed tool call causes the agent to issue a destructive request, the token happily authorizes it, because nothing at the authentication layer distinguishes “the user asked for this” from “the agent decided to do this.” Ability scoping is the mechanism that puts a ceiling on the damage an agent’s token can do, independent of how well your prompt-level defenses hold up. It pairs directly with ingress-level prompt injection defenses: one narrows what gets into the model, the other narrows what a compromised or manipulated agent call is authorized to execute once it reaches your API.
Production Pitfall A token issued with no abilities argument at all defaults to unrestricted access in Sanctum’s model. If your agent-issuing code path forgets the second argument to
createToken, you have silently granted full account privileges. Treat an empty abilities array as a bug, not a fallback.
How a scoped token reaches your route handler
Designing an Ability Vocabulary for Agent Roles
Sanctum abilities are plain strings, which means the naming convention is entirely your responsibility. A flat list of action names (“read”, “write”, “delete”) scales poorly once you have more than one type of agent client. A namespaced convention holds up better:
// Read-only reporting agent
$user->createToken('reporting-agent', [
'orders:read',
'invoices:read',
]);
// Support agent permitted to issue refunds under a limit
$user->createToken('support-agent', [
'orders:read',
'refunds:create',
]);
// Mobile client, broader but still bounded
$user->createToken('mobile-app', [
'orders:read',
'orders:write',
'profile:manage',
]);
Each ability describes a resource and an action on that resource. This gives you a vocabulary that maps cleanly onto both tokenCan checks in policies and route middleware, and it reads clearly in an audit log when you are reviewing which agent did what.
Architect’s Note Resist the temptation to encode business rules inside the ability string itself, such as
refunds:create:under-100. Abilities answer “is this action permitted at all,” while the specific limit belongs in your policy or service layer, where it can reference the actual order total. Overloading ability strings with conditional logic makes them brittle and hard to test.
Enforcing Abilities at the Route and Policy Layer
Sanctum exposes two middleware for ability checks, registered as aliases in bootstrap/app.php:
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
->withMiddleware(a(Middleware $middleware): void {
$middleware->alias([
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
})
With the aliases registered, routes declare their requirements directly:
// Requires every listed ability
Route::post('/api/refunds', RefundController::class)
->middleware(['auth:sanctum', 'abilities:orders:read,refunds:create']);
// Requires at least one of the listed abilities
Route::get('/api/orders/{order}', OrderController::class)
->middleware(['auth:sanctum', 'ability:orders:read,orders:write']);
For checks that need more context than a route middleware can express, such as confirming the order actually belongs to the requesting user, push the ability check into a policy alongside the ownership check:
public function refund(User $user, Order $order): bool
{
return $user->id === $order->user_id
&& $user->tokenCan('refunds:create');
}
This mirrors the pattern Laravel documents for first-party requests: tokenCan always returns true for session-authenticated first-party UI requests, so your policies remain the single source of truth for whether an action is actually allowed, regardless of whether the caller is a browser session or an agent token. Do not treat route middleware and policy checks as redundant. The middleware rejects obviously out-of-scope requests early and cheaply; the policy enforces the ownership and business-rule logic that abilities alone cannot express.
Token Expiration and Rotation for Agent Credentials
By default, Sanctum tokens never expire. That default is reasonable for a human’s long-lived personal access token, and it is a poor fit for an agent process that runs continuously and holds its credential in an environment variable or a secrets store. Set an expiration when issuing the token:
$token = $user->createToken(
'reporting-agent',
['orders:read', 'invoices:read'],
now()->addWeek()
)->plainTextToken;
Expired tokens accumulate in the personal_access_tokens table unless you prune them. Schedule the cleanup in routes/console.php, not in a Kernel.php schedule method:
use Illuminate\Support\Facades\Schedule;
Schedule::command('sanctum:prune-expired --hours=24')->daily();
Production Pitfall A short expiration window without a corresponding refresh flow just relocates the failure mode: your agent’s next scheduled job fails with a 401 instead of executing an unscoped action. Pair expiration with a rotation job that reissues a fresh token before the old one lapses, and revoke the previous one explicitly with
$user->tokens()->where('id', $tokenId)->delete()once the new one is confirmed working. This matters more as your deployment pipeline matures; the rotation cadence you pick here should line up with the broader release and credential-handling practices in your production deployment guide, not exist as an isolated cron job nobody remembers.
Testing Ability-Scoped Endpoints
Sanctum’s actingAs method lets you assert both authentication and ability scope in the same test, without issuing a real token over HTTP:
use App\Models\User;
use Laravel\Sanctum\Sanctum;
test('agent without refund ability is rejected', function () {
Sanctum::actingAs(
User::factory()->create(),
['orders:read']
);
$response = $this->postJson('/api/refunds', ['order_id' => 1]);
$response->assertForbidden();
});
test('agent with refund ability can issue a refund', function () {
Sanctum::actingAs(
User::factory()->create(),
['orders:read', 'refunds:create']
);
$response = $this->postJson('/api/refunds', ['order_id' => 1]);
$response->assertOk();
});
Writing the negative case, where the token lacks the ability, is what actually validates your middleware and policy configuration. A test suite that only ever grants ['*'] proves nothing about whether scoping works.
Sanctum Abilities vs OAuth 2.1: Choosing the Right Boundary
Sanctum abilities and OAuth 2.1 with PKCE solve related but distinct problems, and conflating them leads to either over-engineering a simple internal integration or under-securing a genuinely external one.
| Dimension | Sanctum Abilities | OAuth 2.1 + PKCE |
|---|---|---|
| Trust boundary | First-party: your app, your users, your infrastructure | Third-party: external clients you do not control |
| Credential issuance | Direct, via createToken in your own code | Authorization code flow, consent screen |
| Typical holder | Internal agent, SPA, mobile client | Remote MCP client, external integration partner |
| Revocation model | Delete the row from personal_access_tokens | Token introspection and revocation endpoints |
| Protocol overhead | Minimal | Higher, by design |
If your AI agent runs inside your own infrastructure, reads your own database, and was deployed by your own team, Sanctum abilities are the correct tool. The moment you are exposing an MCP server to remote, third-party consumers, the trust assumptions change enough that OAuth 2.1 becomes the appropriate boundary. Using Sanctum abilities to secure a remote-facing MCP endpoint, or building a full OAuth flow for an agent that never leaves your own VPC, both add complexity without a corresponding security benefit.
Field Note The decision point is not “which is more secure” in the abstract. It is “who issues the credential and who decides what it is scoped to.” If that is entirely your own team, stay with abilities. If an external party’s user is involved in the authorization decision, you need the consent and delegation semantics OAuth provides.
Where This Fits With Agent Approval Flows
Ability scoping is a static boundary set at token issuance. It does not know that a refund of $50 is routine while a refund of $5,000 warrants a human look. For actions above that kind of threshold, ability scoping should work alongside, not instead of, an approval gate before the agent executes the action. Abilities answer “is this action category permitted at all.” Approval flows answer “should this specific instance proceed.” Neither one substitutes for the other.
Summary
Ability scoping turns Sanctum from a binary authentication check into a genuine authorization layer, which matters far more once agents rather than humans are the ones holding tokens. The core trade-off is upfront design cost: you need a deliberate ability vocabulary and a testing discipline that actually exercises the rejection path, not just the happy path. In exchange, a compromised or misbehaving agent token is contained to a defined blast radius instead of inheriting full account access. Within the deployment operations layer, this sits between baseline Sanctum setup and the OAuth 2.1 pattern used for genuinely external MCP consumers, and it is the piece most teams skip until an incident forces the question.
Have you built out a specific ability naming scheme for agent tokens in your own application, or run into a case where route middleware alone was not enough to express the permission boundary? I would be interested in how you structured it.
Frequently Asked Questions
Do Sanctum abilities work the same way for SPA session authentication and API tokens?
No. For first-party SPA requests authenticated via session cookie, tokenCan always returns true regardless of any ability list, because there is no discrete token object to check. Ability enforcement is only meaningful for genuine API token requests, such as those from an agent or mobile client using the Authorization header.
Can I change a token’s abilities after it has been issued?
Not directly. Abilities are stored on the personal_access_tokens row at creation. To change them, revoke the existing token and issue a new one with the updated ability list, then update whatever is holding the old credential.
What happens if an agent’s token has no abilities at all?
An empty abilities array means the token can authenticate but tokenCan will return false for every check. This is different from omitting the abilities argument, which grants unrestricted access. Always pass an explicit array, even an empty one, rather than relying on the default.
Should every internal microservice call use a scoped Sanctum token?
If the services share infrastructure and a compromise of one does not meaningfully change your risk posture, a scoped token is reasonable overhead. If the services cross a genuine trust or network boundary, treat that call the same way you would treat a third-party integration and consider OAuth 2.1 instead.
A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".
