Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Airlock Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Note on naming: laravel/airlock was the pre-2.0 name of this package. The repository is now laravel/sanctum and the Composer package is laravel/sanctum. If you find airlock in an old composer.json or 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.
  • The official docs at laravel.com/docs/sanctum, which are far more complete than the README.

Implementation Patterns

SPA cookie authentication (the stateful path)

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.

  1. Configure stateful domains in config/sanctum.php or via env:
SANCTUM_STATEFUL_DOMAINS=localhost:3000,app.example.com
SESSION_DOMAIN=.example.com
  1. Attach the middleware to your API group (Laravel 11+ skeleton: bootstrap/app.php; older: app/Http/Kernel.php):
->withMiddleware(function (Middleware $middleware) {
    $middleware->statefulApi();
})
  1. 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.

  2. 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.

API token issuance patterns

// 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

Abilities and scopes

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.

Global expiration and per-token expiration

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.

Housekeeping

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.

Token identification (prefix + checksum)

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.

Testing

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.

Gotchas and Tips

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."

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
bernskioldmedia/laravel-matomo
bernskioldmedia/laravel-harvest
bernskiold/laravel-data-scrubber
bernskiold/laravel-currency-converter
bernskiold/laravel-campaign-monitor
bernskioldmedia/laravel-autotranslate
siberfx/telegram-bot
roy-sandip/laravel-view-extended
gregsphotography/site-builder-core
roy-sandip/laravel-view
anodyne/blade-fluentui-icons
webx-ui/module-catalog-collections
webx-ui/module-catalog-feeds
makidizajnerica/laravel-dto
matviib/scheduler
webx-ui/templates
anodyne/blade-tabler-icons
bref/laravel-websockets
anodyne/blade-hugeicons
1994/flatpack-statamic