laravel/sanctum
Laravel Sanctum provides featherweight authentication for SPAs and simple APIs using token-based and session-based auth. Install it to secure your API routes with minimal configuration.
Install and scaffold. On Laravel 11+ the fastest path is the first-party installer, which pulls in Sanctum, creates routes/api.php, and wires the api middleware group:
php artisan install:api
On older apps (or if you want the manual route):
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Then give your authenticatable model the trait and contract:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
}
Make one architectural decision up front. Sanctum is two auth systems in one package, and the biggest day-one mistake is mixing them:
| Consumer | Mode | Credential |
|---|---|---|
| Your own SPA on the same top-level domain | Cookie/session (stateful) | Laravel session cookie + CSRF token |
| Mobile app, CLI, third-party API, machine-to-machine | API tokens | Authorization: Bearer <plainTextToken> |
Both modes are served by the same auth:sanctum guard, which tries the token first and falls back to the configured session guards — that's why one route file can serve both.
Where to look first.
config/sanctum.php — stateful, guard, expiration, token_prefix, middleware.Laravel\Sanctum\HasApiTokens — createToken(), tokens(), currentAccessToken(), tokenCan().Laravel\Sanctum\PersonalAccessToken — the model behind personal_access_tokens, and the Sanctum::usePersonalAccessTokenModel() extension point.First working use case — a token-protected endpoint:
// routes/api.php
Route::post('/tokens', function (Request $request) {
$request->validate(['email' => 'required', 'password' => 'required', 'device_name' => 'required']);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages(['email' => 'Invalid credentials.']);
}
return ['token' => $user->createToken($request->device_name)->plainTextToken];
});
Route::middleware('auth:sanctum')->get('/user', fn (Request $r) => $r->user());
The plain-text token is returned once and never recoverable — the database stores a SHA-256 hash of it.
SPA (cookie) flow. Add $middleware->statefulApi() in bootstrap/app.php so EnsureFrontendRequestsAreStateful is applied to the api group, list the SPA origin in SANCTUM_STATEFUL_DOMAINS, then:
axios.defaults.withCredentials = true;
await axios.get('/sanctum/csrf-cookie'); // sets XSRF-TOKEN cookie
await axios.post('/login', credentials); // normal Laravel session login
await axios.get('/api/user'); // authenticated via session
No tokens are involved; /logout is a normal session invalidate.
Token issuance patterns. One token per device is the canonical pattern — it gives you per-device revocation and readable audit data:
$token = $user->createToken(
name: $request->device_name,
abilities: ['posts:read', 'posts:write'],
expiresAt: now()->addDays(30), // per-token expiry, independent of config
);
Authorization: two layers. Use abilities as a coarse scope on the token itself, policies for fine-grained rules:
Route::middleware(['auth:sanctum', 'abilities:posts:write'])->group(...);
Route::middleware(['auth:sanctum', 'ability:posts:write,posts:admin'])->group(...); // any-of
if ($request->user()->tokenCan('posts:write')) { /* ... */ }
Ability strings are arbitrary; * is the wildcard. Keep the vocabulary small and stable — abilities are baked into tokens at issuance time, so renaming one silently invalidates every outstanding token that used it.
Revocation and lifecycle. Think about logout as three distinct operations:
$request->user()->currentAccessToken()->delete(); // this device
$request->user()->tokens()->delete(); // all devices (password change, "log out everywhere")
$user->tokens()->where('last_used_at', '<', now()->subMonths(3))->delete(); // hygiene
Hook tokens()->delete() into password-change and account-compromise flows; it's the cheapest security win in the package.
Expiry and pruning. sanctum.expiration is a global TTL in minutes (null = never). Expired tokens are rejected by the guard but remain in the table, so schedule the cleanup command:
Schedule::command('sanctum:prune-expired --hours=24')->daily();
Never-expiring tokens are invisible to that command — pair it with a last_used_at sweep for abandoned devices.
Customising the pipeline. Three extension points cover most real-world needs:
// AppServiceProvider::boot()
Sanctum::usePersonalAccessTokenModel(CompanyToken::class); // extra columns, tenancy, audit
Sanctum::getAccessTokenFromRequestUsing( // non-standard header / query param
fn ($request) => $request->header('X-Api-Key')
);
Sanctum::$accessTokenAuthenticationCallback = fn ($token, $request) => $token->user; // custom validation
Listen for Laravel\Sanctum\Events\TokenAuthenticated to log usage, enforce IP allow-lists, or detect token replay.
Testing. Sanctum::actingAs() is the whole test story and is dramatically faster than minting real tokens:
Sanctum::actingAs($user, ['posts:write']); // abilities granted; defaults to ['*']
$this->getJson('/api/posts')->assertOk();
For SPA tests, plain $this->actingAs($user) exercises the session path, which is a genuinely different code path — test both.
tokenCan() returns true for cookie-authenticated requests. This is the #1 source of broken authorization. A session request has no token, so ability checks pass unconditionally. Never guard SPA routes with tokenCan() or the abilities middleware — use Gates and Policies. Reserve abilities for bearer-token clients.
419 errors are almost always stateful-domain configuration. The origin must match sans scheme and with the port (localhost:5173, not http://localhost:5173), SESSION_DOMAIN must cover both hosts, CORS must set supports_credentials: true with explicit origins (not *), and the client must send withCredentials. Wildcards (*.example.com) work, and any request without an Origin/Referer is treated as non-stateful — which is why raw curl tests against SPA routes behave differently than the browser.
The plain-text token is unrecoverable. Only id|hash lives in the database. Never log it, never email it after signup, and make the client persist it immediately. If a user loses it, issue a new one — there is no "show token again" endpoint to build.
Changing token_prefix or the personal_access_tokens schema invalidates live tokens. The prefix is part of the hashed string; rotating it is a breaking change for every issued token. Ship it before launch or plan a forced re-auth.
last_used_at costs a write per request. It's an UPDATE on every authenticated call, which shows up under load and on read replicas. Sanctum 4.3 added a config toggle to control this tracking — disable it for high-traffic APIs and rely on expires_at instead. Older versions also had Octane and connection-resolution bugs around this column, so keep the package current.
Token cleanup isn't automatic on user deletion. personal_access_tokens uses a polymorphic relation without a foreign key by default; deleted users leave orphans. Cascade manually:
static::deleting(fn ($user) => $user->tokens()->delete());
Middleware order matters. abilities must run after auth:sanctum — on an unauthenticated request it has no token to inspect. If the alias isn't resolving (Class "abilities" does not exist), register it explicitly in bootstrap/app.php with a custom middleware setup.
Config caching traps. SANCTUM_STATEFUL_DOMAINS and the guard list are read from cached config; a stale config:cache after changing domains produces 419s that survive a code deploy. Also verify config/auth.php has 'api' => ['driver' => 'sanctum', ...] and that sanctum.guard contains every guard your SPA can log in through — a second guard (e.g. admin) not listed there means session auth silently fails for it.
Performance hygiene. Sanctum 4.2 added indexes on personal_access_tokens — apply that migration rather than hand-rolling the table, and keep the token lookup keyed by the ID prefix (which the package already does) so lookups stay index-only. Long token names are safe now that the column is text.
Don't over-scope. If you find yourself building refresh tokens, token rotation on every request, or a scopes UI, Sanctum is telling you the requirements have outgrown it — that's Passport territory. Sanctum's value is that the whole surface is ~5 methods and one migration; keep it that way.
How can I help you explore Laravel packages today?