laravel/airlock
Laravel Airlock (now Sanctum) provides a featherweight authentication system for SPAs and simple APIs, offering token-based API auth and session-based SPA auth without the overhead of OAuth.
Note on naming:
laravel/airlockwas the pre-2.0 name of this package. The repository is nowlaravel/sanctumand the Composer package islaravel/sanctum. If you findairlockin an oldcomposer.jsonor a stale tutorial, everything below still applies — just swap the package and config file names.
1. Install and scaffold
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
This gives you config/sanctum.php and the personal_access_tokens migration (a polymorphic table keyed to any tokenable model, not just User).
2. Add the trait to your authenticatable model
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
}
HasApiTokens gives you createToken(), tokens(), currentAccessToken(), tokenCan(), and tokenCant(). There is also a HasApiTokens contract if you prefer interface-based typing over trait detection.
3. Pick your flavour. Sanctum solves two different problems and you should decide which one you're solving before writing code:
| Scenario | Mechanism | Guard |
|---|---|---|
| First-party SPA on the same top-level domain | Session cookie + CSRF | auth:sanctum with EnsureFrontendRequestsAreStateful |
| Mobile app / third-party API / CLI | Opaque bearer tokens in personal_access_tokens |
auth:sanctum |
4. First use case — the login endpoint
Route::post('/login', function (Request $request) {
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages(['email' => 'Bad credentials.']);
}
return ['token' => $user->createToken('mobile-app')->plainTextToken];
});
Route::middleware('auth:sanctum')->get('/user', fn (Request $r) => $r->user());
Where to look first
config/sanctum.php — stateful, guard, expiration, prefix, middleware.Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful — this is the class that decides cookie-vs-token per request. Most "why is my SPA getting 401/419" bugs live in its fromFrontend() logic.Laravel\Sanctum\Guard — the actual guard implementation; read it once, it's short, and it explains the ordering of token lookup, expiration checks, and last_used_at.This is the flow most people actually want for a Vue/React/Inertia frontend, and it's the part that trips people up because it looks like token auth but isn't.
config/sanctum.php or via env:SANCTUM_STATEFUL_DOMAINS=localhost:3000,app.example.com
SESSION_DOMAIN=.example.com
bootstrap/app.php; older: app/Http/Kernel.php):->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})
The frontend must hit GET /sanctum/csrf-cookie once, then send X-XSRF-TOKEN on subsequent requests. Axios does this automatically if you set withCredentials: true and withXSRFToken: true.
Login is a session login, not a token issuance:
Route::post('/login', function (Request $request) {
$request->validate(['email' => 'required', 'password' => 'required']);
if (! Auth::attempt($request->only('email', 'password'))) {
throw ValidationException::withMessages(['email' => 'Bad credentials.']);
}
$request->session()->regenerate();
return response()->noContent();
});
No token is created; the session cookie itself authenticates the request. EnsureFrontendRequestsAreStateful makes the guard fall back to the session guard for requests from the configured domains — that's the whole trick.
// Named per device, so users can revoke individually
$token = $user->createToken(
name: $request->device_name,
abilities: ['posts:read', 'posts:write'],
expiresAt: now()->addDays(30),
);
return ['token' => $token->plainTextToken]; // shown exactly once
Returning the model alongside lets you hand back the token id for a "revoke this device" UI:
['token' => $plain, 'id' => $token->accessToken->id]
Revocation lives on the model:
$request->user()->currentAccessToken()->delete(); // logout this token
$request->user()->tokens()->delete(); // logout everywhere
$request->user()->tokens()->where('id', $id)->delete(); // revoke a specific device
Route::middleware(['auth:sanctum', 'ability:posts:write'])->post('/posts', ...);
Route::middleware(['auth:sanctum', 'abilities:posts:read,posts:write'])->get('/posts', ...);
Inside controllers, the model-level helpers are usually nicer:
if ($request->user()->tokenCan('posts:write')) { ... }
if ($request->user()->tokenCant('posts:write')) { ... } // added in 4.0.7
A TransientToken returned by the session guard's currentAccessToken() reports true for every ability — which is what you want: an SPA user authenticated by session gets the full authority of the user, whereas a token gets scoped authority.
config/sanctum.php has an expiration value (minutes; null = never). Since v3.0.0 there is also a per-token expires_at column, so you can mix: a long-lived global policy with short-lived tokens for sensitive issuances. Expired tokens are rejected by the guard at authentication time.
php artisan sanctum:prune-expired --hours=24
Schedule it — nothing prunes expires_at rows for you. Prune only removes expired tokens; stale never-expiring tokens from old devices remain until a user or a custom job removes them.
Since v3.2.6, Sanctum can prefix tokens and append a checksum so leaked tokens are scannable by secret scanners, and so you can validate format before hitting the DB. Configure via sanctum.token_prefix. Note the v3.3.0 change: no prefix is applied by default, so don't assume one exists. If you add a prefix, keep the Guard's token lookup behaviour in mind — it splits on the prefix/checksum delimiters.
use Laravel\Sanctum\Sanctum;
Sanctum::actingAs($user, ['posts:read']); // default guard
Sanctum::actingAs($user, ['posts:read'], 'admin'); // named guard
$this->getJson('/api/posts')->assertOk();
Sanctum::actingAs authenticates as the guard and returns a TransientToken pre-seeded with the given abilities, so tokenCan() assertions work without a database round-trip and without creating real tokens. The one caveat worth remembering: if you pass ['*'] (or omit abilities) the transient token allows everything, so tests can silently pass route ability: middleware that production would block — always be explicit about the abilities you're testing.
419 / CSRF errors on a SPA. Almost always one of: (a) EnsureFrontendRequestsAreStateful isn't on the route group, so the request is treated as token-authenticated and the session/CSRF middleware never runs; (b) the frontend never called /sanctum/csrf-cookie; (c) SESSION_DOMAIN and SANCTUM_STATEFUL_DOMAINS disagree on subdomain; (d) supports_credentials is false in config/cors.php. You cannot use allowed_origins: ['*'] with credentials — list origins explicitly.
Stateful matching is exact-ish about scheme and port. localhost:3000 ≠ localhost. Wildcards (*.example.com) work, but the check is host/port based; https vs http matters when currentRequestHost() builds the comparison string. If a request "should" be stateful and isn't, dump what EnsureFrontendRequestsAreStateful::fromFrontend() computes — that's the ground truth.
Middleware ordering matters. EnsureFrontendRequestsAreStateful should sit at the front of the api middleware group because it injects session/CSRF middleware before the rest of the stack runs. If it's placed after throttle or after route-level middleware, sessions won't be started when the guard evaluates.
Session driver must be cookie-based for SPA auth. SESSION_DRIVER=token or array breaks stateful auth in confusing, intermittent ways. Use cookie, file, redis, or database.
UUID / ULID user models. The stock migration uses morphs('tokenable') (integer tokenable_id). If your User uses UUID keys, change it to uuidMorphs('tokenable') (or ulidMorphs) before php artisan migrate, or you'll get truncation/silent misses on lookup.
Don't re-display plainTextToken. It's the plaintext of a hashed column; the DB stores a SHA-256 hash. There is no "show me the token again" — the recovery path is to issue a new one. Plan your UI for that.
last_used_at writes on every request. Sanctum updates the token's timestamp per authenticated request, which is a write on a hot path. v4.3.0 added configuration to disable last_used_at tracking — turn it off for high-throughput endpoints if you don't need "last seen" data.
Same-domain "stateful by default" behaviour (4.1.0+). Requests identified as coming from the same domain as the app can be treated as frontend requests and get session/CSRF treatment. This is usually what you want, but be careful with API clients hitting the app's own hostname with a bearer token — they'll be pulled into the stateful path and can start getting 419s. Explicitly listing stateful domains is often clearer than relying on the heuristic.
Custom PersonalAccessToken model. If you need extra columns, soft deletes, or a different table, extend Laravel\Sanctum\PersonalAccessToken, then call Sanctum::usePersonalAccessTokenModel(YourModel::class) in a service provider. This is also the seam for adding tenancy scoping (e.g. global scope on tenant_id).
Custom token extraction. Sanctum::getAccessTokenFromRequestUsing(fn ($request) => ...) overrides bearer-token parsing — useful if you need to accept tokens from a header like X-Api-Key or from a cookie. Since v3.3.2 the callback may return null for "no token present," so always guard against that.
Auth guard config. config/auth.php should have 'sanctum' => ['driver' => 'sanctum', 'provider' => null] — the provider is intentionally null so the tokenable model resolves from the token, not the provider. Setting a provider here causes the classic "wrong user resolved" bug with multi-model tokenables.
When not to use Sanctum. It's opaque-token auth, not OAuth2. No refresh tokens, no authorization-code flow, no fine-grained token delegation to third parties. If you need third-party scopes/consent, use Passport and treat Sanctum as your first-party-only layer. Trying to build refresh-token rotation on top of Sanctum tokens is a smell.
Config caching. After touching config/sanctum.php or SANCTUM_STATEFUL_DOMAINS, run php artisan config:clear locally / redeploy with config:cache in CI. Stale cached config is a recurring cause of "it works locally, 401 in staging."
How can I help you explore Laravel packages today?