Default tokens never expire, carry no scope and cannot be cleanly rotated. All of that is configurable. You just have to know where to look.

The Token That Came Back to Life
A user installs your app, logs in and gets a Sanctum token. The token goes into the app’s secure storage on their phone. They use the app for eight months, then get a new phone and forget about the old one entirely.
The old phone gets factory-reset and sold. The new owner pokes around, finds a cached token in the app’s data directory during a backup extraction. They send a request to your API with that token in the Authorization header.
It works.
Not because your API is broken. Not because Sanctum is broken. It works because nobody told Sanctum that eight-month-old tokens should stop working. The Laravel docs are clear about this: tokens “typically have a very long expiration time (years).” That is not a bug description. That is the default design.
This is the exact pattern OWASP names in API2:2023: Broken Authentication. The vulnerability is not a clever exploit. It is a missing config value and three missing lines of code that nobody wrote because the framework did not force the issue.
What Sanctum Gives You (and What It Leaves Open)
Sanctum’s personal access token system has one genuinely useful property that JWT does not: revocation is instant and database-backed. When you call $user->currentAccessToken()->delete(), that token is dead on the next request. No waiting for a JWT TTL to drain. No blacklist table with a separate lookup path. Just gone.
That is worth appreciating before going any further.
What the default setup does not give you:
No expiry. Tokens created with createToken() never expire unless you configure the expiration option in config/sanctum.php or pass a per-token TTL as the third argument to createToken().
No abilities. A default token has implicit star scope. It can reach every endpoint that the authenticated user can reach. If it gets captured, so does everything.
No pruning. Revoked and expired tokens accumulate in personal_access_tokens indefinitely. On a high-traffic app with weekly login cycles, that table grows fast.
No rotation. There is no built-in mechanism to issue a new token and revoke the old one in a single atomic operation. You build that yourself.
None of these are arguments against Sanctum. They are arguments for knowing what you are configuring.
OWASP API2:2023: Broken Authentication in Context
The 2023 OWASP API Security Top 10 places Broken Authentication second, below only Broken Object Level Authorization. The spec defines it as “failures in authentication mechanisms that allow attackers to compromise authentication tokens or exploit implementation flaws to assume other users’ identities temporarily or permanently.”
The four attack scenarios it names that matter most for a Sanctum setup:
- Using a previously captured token that the API still accepts because it was never expired or revoked
- Performing operations with a token that should not have access to that operation (overpermissioned token)
- Exploiting missing rate limits on authentication endpoints to brute-force or credential-stuff
- Reusing tokens across sessions without rotation
Points 1 and 2 are direct consequences of a default Sanctum configuration. Points 3 and 4 require additional work regardless of which auth package you use, but Sanctum gives you handles for addressing them.
The rest of this article is those handles.
One more thing worth stating plainly: OWASP does not grade on effort. A token that never expires because the developer was busy looks identical to one that never expires because the developer intended it. The remediation is the same either way.
Scoping Tokens with Abilities
The fastest way to reduce blast radius from a captured token is to scope it to the minimum set of actions it needs.
Sanctum calls these abilities. They work like OAuth scopes: strings you define, checked against the token record on each request.
When creating a token, pass abilities as the second argument:
// Mobile app token: can read profile and orders, cannot write anything
$token = $user->createToken('mobile-v3', ['read:profile', 'read:orders']);
return response()->json([
'token' => $token->plainTextToken,
]);Check abilities in controllers using tokenCan() or tokenCant():
public function updateProfile(Request $request): JsonResponse
{
if ($request->user()->tokenCant('write:profile')) {
return response()->json(['error' => 'Insufficient token scope'], 403);
}
// proceed with update
}For route-level enforcement, register Sanctum’s ability middleware in bootstrap/app.php:
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
})Then attach to routes:
// Token must have BOTH abilities
Route::get('/orders', [OrderController::class, 'index'])
->middleware(['auth:sanctum', 'abilities:read:orders,read:profile']);
// Token must have AT LEAST ONE ability
Route::post('/orders', [OrderController::class, 'store'])
->middleware(['auth:sanctum', 'ability:write:orders,admin:orders']);CheckAbilities requires the token to hold every listed ability while CheckForAnyAbility passes if the token holds at least one. Pick the right one per route. Mixing them up silently opens gaps.
One practical note: first-party SPA requests authenticated via cookie will always return true from tokenCan(). That is intentional per the Sanctum design, documented explicitly. Your authorization policies handle the actual permission check for those requests. This only affects API token clients.
Configuring Token Expiration
Open config/sanctum.php. Find the expiration key:
// Minutes. Default is null (never expires).
// 525600 = 1 year. 10080 = 7 days. 1440 = 24 hours.
'expiration' => 10080, // 7 daysSetting this applies to all tokens created after the change. Tokens already in the database keep their original TTL (null, meaning they do not retroactively expire). For legacy tokens, you will need a one-time cleanup script.
For per-token expiry, pass a Carbon date as the third argument:
// Short-lived token for a password reset confirmation step
$resetToken = $user->createToken(
'password-reset',
['write:password'],
now()->addMinutes(15)
);
// Standard API client token with a 30-day TTL
$apiToken = $user->createToken(
'api-client-' . $clientId,
['read:data', 'write:data'],
now()->addDays(30)
);Once you configure expiry, expired records pile up in personal_access_tokens and Sanctum will still query against them. Schedule cleanup:
// In routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;
Schedule::command('sanctum:prune-expired --hours=24')->daily();The --hours flag sets how stale an expired token must be before deletion. --hours=24 means a token that expired yesterday gets pruned today. For 15-minute tokens in a high-volume system, prune every hour instead.
Building a Token Rotation Endpoint
Rotation is the practice of issuing a new token and revoking the old one on each authentication cycle. A token captured in transit last week is useless after the next rotation.
The simplest rotation endpoint:
// POST /api/auth/rotate
public function rotate(Request $request): JsonResponse
{
$currentToken = $request->user()->currentAccessToken();
$abilities = $currentToken->abilities ?? [];
// Revoke the token used for this request
$currentToken->delete();
// Issue a replacement with the same abilities and a fresh TTL
$newToken = $request->user()->createToken(
'rotated-' . now()->timestamp,
$abilities,
now()->addDays(7)
);
return response()->json([
'token' => $newToken->plainTextToken,
'expires_at' => now()->addDays(7)->toISOString(),
]);
}Mobile clients call this on each app launch or on a defined schedule. The current token dies. The new token has a fresh seven-day window. A captured token from last Tuesday’s session stops working the moment the legitimate client next rotates.
For API-to-API clients where rotation should be invisible, trigger it on login and return both the user payload and the new token in the same response. The client replaces its stored token silently. No separate rotation call needed.
Revoking Tokens Across Devices
When a user reports a device stolen, you need to revoke tokens on that device without ending sessions on their laptop or tablet.
The key is token naming at creation time. Use a device identifier in the token name:
// Mobile login endpoint: token name carries device context
$deviceId = $request->header('X-Device-ID', 'unknown-device');
$token = $user->createToken(
'device:' . $deviceId,
['read:profile', 'read:orders', 'write:orders'],
now()->addDays(30)
);Then revoke by device:
// Revoke all tokens associated with a specific device
$user->tokens()
->where('name', 'like', 'device:' . $deviceId . '%')
->delete();Expose a token list to users in account settings so they can see active sessions and revoke specific ones. This pattern requires a bit of frontend work but it substantially reduces the support burden when users report device theft.
The alternative is nuclear revocation: $user->tokens()->delete() kills every token the user has. Fast, but it logs the user out of every device. Use it when the account itself is compromised, not just one device.
What to Log
Sanctum updates last_used_at on each request. That tells you a token was used. It does not tell you which endpoint was hit or what action was taken.
For any app in a regulated industry (fintech, healthcare, anything handling PII), you need that data. A lightweight approach is to log on authenticated requests from a middleware:
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$token = $request->user()?->currentAccessToken();
if ($token) {
logger()->channel('api_audit')->info('api_request', [
'user_id' => $request->user()->id,
'token_id' => $token->id,
'token_name' => $token->name,
'route' => $request->route()?->getName(),
'method' => $request->method(),
'ip' => $request->ip(),
'at' => now()->toISOString(),
]);
}
return $response;
}Apply this middleware to specific route groups rather than globally if request volume is high. On a busy API, per-request log writes add up. Keep the audit channel async-backed (a queue-based log channel) to keep it out of the critical path.
A Configuration Checklist
To summarize what a production Sanctum setup needs beyond the defaults:
Set the expiration value in config/sanctum.php. Even a 90-day TTL is a meaningful improvement over never.
Scope every production token with abilities. Wildcard star-scope tokens should exist only in tests and internal tooling, never in client-facing auth flows.
Schedule sanctum:prune-expired to run daily. The personal_access_tokens table accumulates silently otherwise.
Build a rotation endpoint or trigger rotation on login. Short-lived tokens that rotate on use are substantially harder to exploit than long-lived ones that rotate on complaint.
Name tokens with device or client context so you can revoke precisely when you need to.
Log token usage to a separate audit channel if your threat model includes compliance requirements or insider access.
Rate-limit your login and token issuance endpoints. Laravel’s throttle middleware handles this, and OWASP explicitly calls out missing rate limits as a Broken Authentication vector. Apply throttle:5,1 (five attempts per minute) to login routes at a minimum. Adjust for expected legitimate traffic patterns. A B2B API used by automated clients may need wider thresholds, but they should still have thresholds.
None of this requires a library. It is the framework you already have.
OWASP does not distinguish between “we forgot to configure expiry” and “we built a backdoor.” From a breach-causation perspective, the outcome is the same: an attacker holds a valid credential for longer than they should. Most of the production Sanctum configs I have reviewed in client codebases had the expiration key commented out and no abilities on any token. That is not a Laravel problem. It is a "we shipped and moved on" problem. The configuration gap closes with five changes to files you already own.

