Every Laravel Sanctum tutorial ends at createToken('token-name') and auth:sanctum, which is roughly the first ten minutes of a job that takes three hours. The parts that actually bite you in production come later: an ability check that silently passes for every logged-in user, a personal_access_tokens table with four million rows, and a 419 that three engineers spend an afternoon on. This guide builds a real Orders API on Laravel 13 and Sanctum 4.3, in the order you hit the problems.
Choose between Sanctum's two authentication modes#
Before you install anything, decide which of Sanctum's two modes you actually need, because they share a package name and almost nothing else. Mode one is stateless personal access tokens: a row in personal_access_tokens, a Bearer header, no session, no CSRF, ideal for third-party integrations, CLI tools and native mobile apps. Mode two is SPA authentication, which issues no tokens at all — it is Laravel's ordinary encrypted cookie session, plus a middleware that decides whether an incoming request is from a domain you trust enough to attempt session auth on. The auth:sanctum guard serves both, which is why the distinction is so easy to miss.
The decision rule I use is simple: who controls the client? If you control it and it runs in a browser on your own registrable domain, use SPA mode. A cookie marked HttpOnly cannot be read by injected JavaScript; a bearer token sitting in localStorage can. Handing a long-lived bearer token to your own SPA is the single most common self-inflicted Sanctum wound, and it converts every XSS bug into a full credential leak. If you don't control the client — a customer's integration, a partner's server, a mobile app shipped through an app store — use tokens.
| Client | Mode | Credential | Ability checks meaningful? |
|---|---|---|---|
| Your own SPA, same top-level domain | SPA session | HttpOnly cookie |
No — tokenCan() returns true |
| Native mobile app you ship | Token | Bearer token in the keychain | Yes |
| Customer's server-to-server integration | Token | Bearer token | Yes |
| Third-party app acting for many users | Passport (OAuth2) | Access + refresh tokens | Yes (scopes) |
That last row matters. Sanctum deliberately has no authorisation code flow, no consent screen and no refresh tokens. If you are building a platform where third-party developers register applications and end users click "Allow", you want Passport, not Sanctum. If you are building an API where your users generate their own tokens from an account settings screen, Sanctum is correct and Passport is 4,000 lines of OAuth you will never use. For first-party browser login specifically, it is also worth weighing passkeys with Fortify as the credential layer sitting in front of the very same Sanctum session.
You can run both modes in one application, and most real apps do. Nothing special is required: the same auth:sanctum guard handles a cookie-authenticated request from your dashboard and a bearer-token request from a customer's cron job. What changes is your authorisation code, and that is the subject of a later step.
Install Sanctum and prepare the User model#
Install Sanctum through the API scaffolding command, which pulls the package, publishes the migration and creates routes/api.php in one pass. On a Laravel 13 skeleton routes/api.php does not exist until you ask for it, so reach for install:api rather than a bare composer require — it also wires the API route file into bootstrap/app.php for you.
composer require laravel/sanctum
php artisan install:api
php artisan migrate
That migration creates personal_access_tokens with a polymorphic tokenable pair, a name, a unique token column holding a SHA-256 hash, a JSON abilities column, and nullable last_used_at and expires_at timestamps. Note what is not stored: the plaintext token. Sanctum hashes with hash('sha256', $plainTextToken) on write and compares with hash_equals() on read, so a database dump leaks no usable credentials.
Next, add the trait to whichever model issues tokens. It is usually User, but tokenable is a morph, so a Team, Organisation or ApiClient model works identically.
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
One configuration change is worth making on day one. Set a token prefix so that GitHub's secret scanning can recognise your tokens if a customer commits one:
SANCTUM_TOKEN_PREFIX=orders_
Sanctum prepends that string to the random entropy, and GitHub's scanning partner programme can then alert you when a token with your prefix appears in a public repository. It costs nothing and it has saved me a revocation email more than once.
Issue a scoped, expiring token from a controller#
The token-creation endpoint is where most of the security decisions get made, so write it deliberately rather than copying the docs one-liner. createToken() takes three arguments — name, abilities array, and an optional expiry DateTimeInterface — and returns a NewAccessToken whose plainTextToken property is the only place the usable credential will ever exist. Its format is {id}|{plaintext}, which is why Sanctum can look the row up by primary key before doing a constant-time hash comparison instead of scanning the table.
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Support\TokenAbility;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TokenController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:60'],
'abilities' => ['required', 'array', 'min:1'],
'abilities.*' => ['string', Rule::in(TokenAbility::values())],
'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:365'],
]);
$token = $request->user()->createToken(
$validated['name'],
$validated['abilities'],
now()->addDays($validated['expires_in_days'] ?? 90),
);
return response()->json([
'id' => $token->accessToken->getKey(),
'name' => $token->accessToken->name,
'abilities' => $token->accessToken->abilities,
'expires_at' => $token->accessToken->expires_at,
'token' => $token->plainTextToken,
], 201);
}
}
Three things in there are deliberate. The abilities are validated against an allow-list rather than accepted freeform, so a client cannot invent admin:* and have it stored. Expiry defaults to 90 days instead of never, so the worst case for a leaked token is bounded. And plainTextToken is returned exactly once, on the 201 — every subsequent GET /tokens must return the metadata without it, because the plaintext is genuinely unrecoverable. If you are shaping these responses properly rather than returning raw models, the patterns in the complete guide to Laravel 13 JSON:API resources apply here too; a TokenResource that never has a token attribute makes the show-once rule structural rather than a thing you have to remember.
On the frontend, the show-once constraint needs matching UI: a modal that appears after creation with the token in a monospace field, a copy button, and copy that says "this is the only time you will see this". Users who dismiss it need a "regenerate" affordance, not a "reveal" one, because there is nothing to reveal.
Protect API routes with the sanctum guard#
Attach the auth:sanctum guard to every route that requires a user, in routes/api.php and in routes/web.php if your first-party SPA calls web routes. The guard first walks the guards listed in config('sanctum.guard') — ['web'] by default — and returns the session user with a TransientToken attached if one is found. Only if no session user exists does it fall back to reading Authorization: Bearer, look the token up by the id prefix, verify the hash, check expiry, fire a TokenAuthenticated event and update last_used_at.
<?php
use App\Http\Controllers\Api\OrderController;
use App\Http\Controllers\Api\TokenController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::apiResource('tokens', TokenController::class)
->only(['index', 'store', 'destroy']);
Route::get('/orders', [OrderController::class, 'index'])
->middleware('ability:orders:read,orders:write');
Route::post('/orders', [OrderController::class, 'store'])
->middleware('abilities:orders:write');
});
Note the token controller sits inside the guard but outside any ability middleware. That is intentional: creating and revoking tokens is an account-level operation that should be reachable by a session-authenticated user in your dashboard, not by an existing token minting itself a more privileged sibling. If you do want token-issued tokens, gate them behind an explicit tokens:write ability and accept that you have built a privilege-escalation path you now have to reason about.
Design an ability vocabulary that survives a year#
Abilities are just strings in a JSON column, which means the entire design burden is on you, and a vocabulary chosen in ten minutes will be load-bearing for years. Use resource:action — orders:read, orders:write, invoices:read, webhooks:manage — and enumerate it in one place so the allow-list, the token-creation UI and the route middleware all read from the same source of truth. A backed enum is the natural home for this.
<?php
namespace App\Support;
enum TokenAbility: string
{
case OrdersRead = 'orders:read';
case OrdersWrite = 'orders:write';
case InvoicesRead = 'invoices:read';
case InvoicesWrite = 'invoices:write';
case WebhooksManage = 'webhooks:manage';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public function label(): string
{
return match ($this) {
self::OrdersRead => 'Read orders',
self::OrdersWrite => 'Create and update orders',
self::InvoicesRead => 'Read invoices',
self::InvoicesWrite => 'Create and update invoices',
self::WebhooksManage => 'Manage webhook endpoints',
};
}
}
Two traps to avoid. The first is wildcards. PersonalAccessToken::can() is in_array('*', $this->abilities, true) || in_array($ability, $this->abilities, true) — a plain array membership test with no glob matching whatsoever. So orders:* does not grant orders:read; it grants exactly the literal ability orders:* and nothing else. The only magic string is the bare *, which grants everything. If you want prefix wildcards you must implement them yourself by extending PersonalAccessToken and overriding can(), and I would push back on doing so — explicit ability lists are auditable, glob patterns are not.
The second trap is ['*'] itself, which is the default second argument to createToken(). Any call site that omits abilities mints a token that can do anything the user can do, forever. Make abilities a required field in your validation, as above, and the default never fires.
Abilities are a capability ceiling, not an authorisation system. They answer "what is this credential allowed to attempt?", not "is this user allowed to touch this record?". The second question belongs to policies, and if you are running any kind of tenancy the scoping rules in the complete guide to Laravel 13 multi-tenancy with teams still apply on top of every ability check.
Enforce abilities with the abilities and ability middleware#
Sanctum ships two ability middleware, and neither is registered for you. CheckAbilities requires the token to hold all of the listed abilities; CheckForAnyAbility requires at least one. Both throw AuthenticationException when there is no current access token at all, and MissingAbilityException — which renders as a 403 — when the abilities do not line up. Register the aliases yourself in bootstrap/app.php; Sanctum 4.3's service provider configures the guard and middleware priority but does not add these aliases, and app/Http/Kernel.php no longer exists in Laravel 13.
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
$middleware->alias([
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
]);
})
->create();
The naming is unfortunate and worth committing to memory: plural abilities: means AND, singular ability: means OR. abilities:orders:write,invoices:write demands both. ability:orders:read,invoices:read demands either. Reading the two middleware side by side makes it obvious — one loops and throws on the first miss, the other loops and returns on the first hit — but from the route file alone the two look interchangeable, and picking the wrong one silently widens or narrows access.
Also note the ! $request->user()->currentAccessToken() guard at the top of both. A session-authenticated user does have a current access token — a TransientToken — so that check passes and the middleware proceeds to tokenCan(), which is where the next step's problem lives.
Close the tokenCan() hole for session-authenticated users#
This is the part almost nothing on page one of Google mentions, and it is a real authorisation hole in plenty of shipped applications. When Sanctum authenticates a request from a session rather than a bearer token, it calls $user->withAccessToken(new TransientToken). TransientToken::can() is a one-line method that returns true, unconditionally, for every ability you ask it about. So $request->user()->tokenCan('orders:delete') returns true for every logged-in user in your SPA, whether or not such an ability exists anywhere in your vocabulary.
Laravel's documentation frames this as a convenience: it lets you call tokenCan() inside a policy without branching on how the request authenticated. That framing is defensible if the ability check is the second gate and a policy is the first. It is a disaster if tokenCan() is your only gate, because a route protected by nothing more than an ability check is effectively protected by auth alone for every browser client. Here is the failing case, written as a test first:
<?php
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Laravel\Sanctum\TransientToken;
it('leaks admin actions to session users when only tokenCan guards them', function () {
$user = User::factory()->create();
// Simulate first-party SPA session auth.
$this->actingAs($user);
$user->withAccessToken(new TransientToken);
expect($user->tokenCan('orders:destroy-everything'))->toBeTrue();
});
That assertion passes. The ability does not exist in your enum, was never granted to anyone, and tokenCan() still says yes. The fix is to decide explicitly, in one place, what an ability check means for a session request. I use a small helper that treats abilities as a token-only concern and defers to policies for everything else:
<?php
namespace App\Support;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
final class TokenGate
{
/**
* True only when the request is token-authenticated AND the token holds the ability.
* Session-authenticated requests always return false here: authorise them with a policy.
*/
public static function tokenHas(Request $request, string $ability): bool
{
$token = $request->user()?->currentAccessToken();
return $token instanceof PersonalAccessToken && $token->can($ability);
}
public static function isTokenRequest(Request $request): bool
{
return $request->user()?->currentAccessToken() instanceof PersonalAccessToken;
}
}
And the controller then reads as two independent gates — capability, then permission:
// Bad: passes for every session user, regardless of ability.
public function destroy(Request $request, Order $order): Response
{
abort_unless($request->user()->tokenCan('orders:write'), 403);
$order->delete();
return response()->noContent();
}
// Good: policy decides whether the human may act; the ability only narrows tokens.
public function destroy(Request $request, Order $order): Response
{
$this->authorize('delete', $order);
abort_if(
TokenGate::isTokenRequest($request) && ! TokenGate::tokenHas($request, 'orders:write'),
403,
'This token is not permitted to modify orders.',
);
$order->delete();
return response()->noContent();
}
The rule I would put in your team's code review checklist: an ability check is never sufficient on its own. Every write route gets a policy. The ability check exists to stop a read-only integration token from doing something the user themselves is perfectly entitled to do. If you are already modelling permissions properly — the approach in the Filament v5 authorization, policies and roles guide transfers directly — then the policy layer is already there and abilities slot cleanly on top.
Expire, revoke and prune tokens#
Sanctum tokens never expire by default, which is a reasonable library default and a bad production one. You have two independent expiry mechanisms and they interact in a way worth understanding. The global expiration value in config/sanctum.php is a number of minutes measured from created_at, applied to every token, and it overrides whatever is in a token's expires_at column — the guard checks $accessToken->created_at->gt(now()->subMinutes($this->expiration)) before it looks at expires_at at all. The per-token third argument to createToken() sets expires_at and is checked independently.
// config/sanctum.php — a hard ceiling for every token in the app.
'expiration' => 60 * 24 * 365, // one year, in minutes
// Per-token, at the call site.
$user->createToken('CI deploy key', ['orders:read'], now()->addDays(30));
I set the global value as a ceiling — a year is usually right — and use the per-token argument for the real policy. That way a bug in your creation endpoint cannot mint an immortal credential.
Revocation is deletion. There is no revoked flag, no tombstone; the row goes away and the token stops authenticating on the next request.
// Revoke everything — the "I've been compromised" button.
$user->tokens()->delete();
// Revoke just the credential that made this request — "log out this device".
$request->user()->currentAccessToken()->delete();
// Revoke one token by id, from the token management screen.
$user->tokens()->where('id', $tokenId)->delete();
Careful with the middle one in a mixed-mode app: on a session request currentAccessToken() is a TransientToken, which is not an Eloquent model and has no delete() method, so an unguarded call fatals. Wrap it in TokenGate::isTokenRequest() or check instanceof PersonalAccessToken first.
Expired tokens keep occupying rows forever unless you prune them. The sanctum:prune-expired command deletes rows whose expires_at passed more than --hours ago, and — only if config('sanctum.expiration') is set — also deletes rows older than expiration + hours. Schedule it in routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('sanctum:prune-expired --hours=24')->daily();
The 24-hour grace window is deliberate: it gives you a day to answer "which token did this?" during an incident before the evidence is deleted. If you want a longer forensic tail, raise --hours rather than dropping the schedule. This is the same shape of problem as any other growing table, and if you are already using Laravel's Prunable trait to auto-delete stale records elsewhere, note that Sanctum ships its own command rather than using Prunable — you schedule both, separately.
Authenticate a first-party SPA with cookies#
SPA mode issues no tokens. $middleware->statefulApi() prepends EnsureFrontendRequestsAreStateful to the middleware stack; that middleware inspects the request's Origin or Referer against config('sanctum.stateful') and, on a match, runs the full web middleware group — cookie encryption, session start, CSRF validation — before your route. On no match it does nothing and the guard falls through to bearer-token auth. Four settings have to agree with each other, and a mismatch in any one of them produces the 419 that eats afternoons.
APP_URL=https://api.example.com
FRONTEND_URL=https://app.example.com
SANCTUM_STATEFUL_DOMAINS=app.example.com
SESSION_DOMAIN=.example.com
SESSION_DRIVER=redis
// config/cors.php — publish it first with: php artisan config:publish cors
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'allowed_origins' => [env('FRONTEND_URL')],
'supports_credentials' => true,
// resources/js/bootstrap.js
import axios from 'axios';
axios.defaults.baseURL = import.meta.env.VITE_API_URL;
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
export async function login(credentials) {
await axios.get('/sanctum/csrf-cookie');
return axios.post('/login', credentials);
}
The withXSRFToken line is newer than most tutorials. Axios 1.6 stopped attaching the X-XSRF-TOKEN header automatically on cross-origin requests, so a stack that worked on Axios 1.5 started returning 419 on upgrade with no code change. If you are on a different HTTP client, the equivalent is: send cookies, read the XSRF-TOKEN cookie, URL-decode it, and send it back as X-XSRF-TOKEN.
When you do get a 419, it is one of four things, in the order I check them:
- The requesting host is not in
SANCTUM_STATEFUL_DOMAINS— including the port, if you are on127.0.0.1:5173. No match means no session middleware, so no CSRF token to validate against. SESSION_DOMAINis missing the leading dot, so the cookie set byapi.example.comis not sent byapp.example.com.supports_credentialsisfalse, so the browser drops theSet-Cookieon the CORS preflight response entirely.- The SPA never called
GET /sanctum/csrf-cookiebefore its first stateful POST — or called it against a different origin than the one it posts to.
A silent 401 rather than a 419 narrows it further: either the session cookie is being sent but the session is empty (usually a SESSION_DOMAIN/subdomain mismatch), or the SPA and API are on different registrable domains — app.example.com and api.example.io can never share a cookie, no configuration will fix it, and your only options are a shared parent domain or switching that client to token mode. Laravel 13's origin-aware CSRF layer is worth understanding alongside this; I covered it in Laravel 13 PreventRequestForgery, and the Origin-header behaviour there is exactly what EnsureFrontendRequestsAreStateful is matching on.
Rate limit and audit tokens per device#
Rate limiting by user id is wrong for an API with tokens, because it lets one runaway integration exhaust the budget for that user's other five integrations and their browser session too. Key the limiter on the token id instead, falling back to user id for session requests and IP for unauthenticated ones.
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\PersonalAccessToken;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('api', function (Request $request) {
$token = $request->user()?->currentAccessToken();
if ($token instanceof PersonalAccessToken) {
return Limit::perMinute(120)->by('token:'.$token->getKey());
}
return $request->user()
? Limit::perMinute(300)->by('user:'.$request->user()->getKey())
: Limit::perMinute(30)->by($request->ip());
});
}
}
Note the higher ceiling for session traffic: your own dashboard makes far more requests per user than a well-behaved integration does, and throttling it at API rates makes the UI feel broken. For anything more granular than this — per-endpoint budgets, burst allowances, cost-weighted limits — the patterns in fine-grained rate limiting on Laravel API routes go considerably deeper than one named limiter.
Auditing is the other half. last_used_at is maintained for you by the guard on every token-authenticated request, which is enough to power a "last used 3 days ago" column and a quarterly sweep for dormant credentials. What it will not tell you is which token performed a given action, and during an incident that is the only question anyone asks. Push the token identity into the request context so every log line and every queued job carries it:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
class AddTokenContext
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->user()?->currentAccessToken();
Context::add([
'auth_mode' => $token instanceof PersonalAccessToken ? 'token' : 'session',
'user_id' => $request->user()?->getKey(),
'token_id' => $token instanceof PersonalAccessToken ? $token->getKey() : null,
'token_name' => $token instanceof PersonalAccessToken ? $token->name : null,
]);
return $next($request);
}
}
Because Context is serialised into queued jobs, a job dispatched from a token-authenticated request keeps token_id in its log lines too — which is precisely what you want when the thing that went wrong happened asynchronously. The mechanics of that propagation, and how to keep it from leaking between jobs, are covered in Laravel Context: request data that follows your logs and jobs.
Test abilities, expiry and revocation with Pest#
Sanctum::actingAs($user, $abilities) swaps the guard for a fake that attaches a TransientToken-like object with exactly the abilities you list, which makes ability testing trivial and, importantly, makes it possible to assert the denial path that manual testing never covers.
<?php
use App\Models\Order;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('allows reading orders with the orders:read ability', function () {
Sanctum::actingAs(User::factory()->create(), ['orders:read']);
$this->getJson('/api/orders')->assertOk();
});
it('rejects writing orders without the orders:write ability', function () {
Sanctum::actingAs(User::factory()->create(), ['orders:read']);
$this->postJson('/api/orders', ['reference' => 'A-1'])
->assertForbidden();
});
it('rejects an expired token', function () {
$user = User::factory()->create();
$token = $user->createToken('expired', ['orders:read'], now()->subMinute());
$this->withHeader('Authorization', 'Bearer '.$token->plainTextToken)
->getJson('/api/orders')
->assertUnauthorized();
});
it('rejects a revoked token', function () {
$user = User::factory()->create();
$token = $user->createToken('doomed', ['orders:read']);
$token->accessToken->delete();
$this->withHeader('Authorization', 'Bearer '.$token->plainTextToken)
->getJson('/api/orders')
->assertUnauthorized();
});
it('returns the plaintext token exactly once', function () {
$user = User::factory()->create();
Sanctum::actingAs($user, ['*']);
$created = $this->postJson('/api/tokens', [
'name' => 'CI',
'abilities' => ['orders:read'],
])->assertCreated();
expect($created->json('token'))->toStartWith($created->json('id').'|');
$this->getJson('/api/tokens')
->assertOk()
->assertJsonMissingPath('data.0.token');
});
The expired and revoked cases both go through a real Authorization header rather than actingAs, because actingAs bypasses the guard entirely and would happily authenticate a token that no longer exists. That distinction catches a surprising number of bugs. For the SPA path, test the cookie flow end to end — $this->get('/sanctum/csrf-cookie')->assertNoContent(), then a post('/login'), then an authenticated getJson() — and assert that a session-authenticated request to an abilities:-protected route behaves the way your TokenGate says it should, not the way tokenCan() alone would. If your API contract matters to consumers, pairing these with Pest 4 snapshot tests on your API responses will also catch the day someone accidentally adds token back into the index payload.
Roll Sanctum out and avoid the common traps#
Ship it in this order: install and issue tokens with a validated ability allow-list, add the middleware aliases, replace every bare tokenCan() with a policy plus a token-only ability gate, set expiry and schedule pruning, then wire the SPA. Doing the tokenCan() step before the SPA step matters — once cookie auth is live, every ability check in your codebase starts returning true for browser traffic, and you want the guard already in place when that happens.
The mistakes I see most often, in rough order of frequency: handing bearer tokens to a first-party SPA and storing them in localStorage; leaving ['*'] as the ability set because the argument is optional; assuming orders:* matches orders:read; calling currentAccessToken()->delete() on a session request and fatalling on TransientToken; forgetting withCredentials and withXSRFToken after an Axios upgrade; putting the SPA and API on different registrable domains and then trying to configure a way out of it; and logging plainTextToken in the response logger, which quietly writes every credential you have ever issued to disk in plaintext.
From here, three things are worth reading next. If you are hardening the browser side of authentication, passkeys with Fortify on Laravel 13 sits directly in front of the Sanctum session you just configured. If your API is growing past a handful of endpoints, the Laravel 13 JSON:API resources guide covers the response layer that these tokens are protecting. And if the observability step interested you, Laravel Context for request-scoped logging is the deeper treatment of carrying token identity into your queue workers.
FAQ#
What is the difference between Laravel Sanctum and Passport?
Sanctum issues simple personal access tokens — a database row, a bearer string, an array of abilities — with no OAuth machinery at all. Passport is a full OAuth2 server with authorisation code flows, consent screens, client credentials and refresh tokens. Choose Sanctum when your users generate their own tokens for their own integrations; choose Passport when third-party developers register applications that act on behalf of your users and need an "Allow" screen. Sanctum additionally offers cookie-based SPA authentication, which Passport does not.
How do I add abilities or scopes to a Sanctum token?
Pass an array of strings as the second argument to createToken(), for example $user->createToken('CI', ['orders:read', 'invoices:read']). The abilities are stored in a JSON column on personal_access_tokens and checked with tokenCan() or the abilities: and ability: route middleware. Validate the incoming abilities against an allow-list — a backed enum works well — because the default second argument is ['*'], which grants everything.
Why does tokenCan() always return true in my Laravel app?
Because the request authenticated via a session rather than a bearer token. When Sanctum finds a first-party session user, it attaches a TransientToken whose can() method returns true for every ability unconditionally. This is intentional so that policies can call tokenCan() without branching, but it means an ability check alone protects nothing against browser clients. Check that currentAccessToken() is an instance of PersonalAccessToken before treating the result as meaningful, and authorise session requests with a policy.
How do I expire or revoke a Laravel Sanctum token?
For expiry, pass a DateTimeInterface as the third argument to createToken(), or set the expiration value in config/sanctum.php as a global ceiling in minutes. Revocation is deletion: $user->tokens()->delete() for all, $user->tokens()->where('id', $id)->delete() for one, and $request->user()->currentAccessToken()->delete() for the token that made the current request. Schedule sanctum:prune-expired --hours=24 daily so expired rows do not accumulate forever.
Why do I get a 419 CSRF token mismatch with Sanctum SPA authentication?
There are four usual causes. The requesting host is not listed in SANCTUM_STATEFUL_DOMAINS, including its port; SESSION_DOMAIN lacks the leading dot needed to share the cookie across subdomains; supports_credentials is not true in config/cors.php, so the browser discards the session cookie; or the SPA never called GET /sanctum/csrf-cookie before its first stateful POST. Since Axios 1.6 you also need axios.defaults.withXSRFToken = true in addition to withCredentials.
Can I use Sanctum for both a mobile app and a first-party SPA at the same time?
Yes, and this is the intended design. The same auth:sanctum guard handles both: it attempts session authentication first for requests coming from your stateful domains, and falls back to reading the Authorization: Bearer header for everything else. What changes is your authorisation code — because session requests carry a TransientToken, every ability check needs to distinguish the two modes rather than trusting tokenCan() alone.
Where does Sanctum store API tokens and are they hashed?
Tokens live in the personal_access_tokens table, keyed to any model via a polymorphic tokenable relationship. The token column stores a SHA-256 hash of the random string, never the plaintext, and lookups compare with hash_equals() for constant-time safety. The plaintext is returned once as NewAccessToken::$plainTextToken in the format {id}|{random} and is genuinely unrecoverable afterwards, so your UI must show it once and offer regeneration rather than reveal.
How do I rate limit or audit Sanctum token usage?
Key your RateLimiter::for('api', ...) callback on $request->user()?->currentAccessToken()?->getKey() so each token gets its own budget, falling back to user id for session requests and IP for anonymous ones. For auditing, Sanctum maintains last_used_at on every token-authenticated request automatically, and you can push the token id and name into Context in a middleware so every log line and every queued job dispatched from that request carries the credential identity.