Laravel Sanctum API Authentication: Tokens, SPA Sessions & Production Secrets
- Sanctum is two auth systems in one package: opaque token auth for API clients and cookie/session auth for SPAs — they share a guard but use completely different resolution paths and should never be confused with each other.
- The plain-text token from createToken()->plainTextToken is a one-time read: Sanctum stores only the SHA-256 hash. Losing it means issuing a new token, not recovering the old one.
- SANCTUM_STATEFUL_DOMAINS and CORS supports_credentials must always be configured together for SPA auth — getting one right and missing the other is the #1 cause of phantom 401 errors in Laravel SPA setups.
Imagine every ride at a theme park requires a wristband. The ticket booth checks your ID once, snaps on the wristband, and from that point on every ride operator just scans the wristband — they never ask for your ID again. Laravel Sanctum is that wristband system for your API. Your app checks the user's password once, mints a token (the wristband), and every future request just presents the token. Sanctum decides whether that wristband is valid and what rides (routes) it can access. The key thing most people miss: Sanctum actually gives you two completely different wristband systems — one for guests walking up to the rides directly (cookie-based session auth for your own SPA), and one for guests phoning in from outside the park (token-based auth for mobile apps and third-party clients). Same package, two totally different mechanisms.
Laravel Sanctum solves two distinct authentication problems in one lightweight package: token-based auth for mobile apps and CLI tools, and cookie-based session auth for first-party SPAs. Most tutorials blur these two modes together. That confusion is the source of 80% of the 'why isn't this working?' questions on Stack Overflow. If you've ever spent an afternoon staring at a 401 error that makes no sense, chances are you were using the wrong auth driver for your request type without realising it.
Sanctum lives in the sweet spot between 'embed credentials in every request' (a security disaster) and 'stand up a full OAuth 2.0 authorization server' (overkill for most Laravel apps). It's lightweight enough to ship in an afternoon, powerful enough to handle real-world auth for SPAs, mobile clients, and third-party API consumers simultaneously.
I have been building Laravel APIs in production since the Passport days — when Sanctum didn't exist and every API auth setup required a PhD in OAuth grant types. I have migrated three separate applications from Passport to Sanctum, built token-based auth for fintech platforms processing £2M daily, debugged more 401 errors than I care to remember, and watched teams lose weeks to configuration mistakes that a single paragraph of documentation would have prevented. This article is every lesson I wish I'd had on day one.
By the end you'll be able to scaffold a complete token-based API, implement SPA cookie auth with correct CSRF handling, scope tokens with abilities, revoke tokens on logout, implement token refresh and rotation, test your auth flows with PHPUnit, configure Sanctum correctly for production across different domains, and reason confidently about which auth method to reach for in any architecture. You'll also know exactly where Sanctum's internals look for tokens so you can debug auth failures in under two minutes.
How Sanctum Actually Works Under the Hood — Two Drivers, One Package
Sanctum ships with a single middleware class — EnsureFrontendRequestsAreStateful — and a custom sanctum guard. When a request arrives, Sanctum's guard asks one question first: does this request originate from a trusted first-party domain (listed in sanctum.stateful config)? If yes, it authenticates via the standard web session cookie. If no, it falls through to token-based auth and looks for a Bearer token in the Authorization header.
The token lookup itself is elegant but has important performance implications. Sanctum hashes the raw token with SHA-256 and queries the personal_access_tokens table for a matching token column. This means the plain-text token is never stored — only its hash — which protects users even if your database is breached. But it also means every authenticated request triggers at least one database query unless you layer in caching.
The guard is registered in config/auth.php as a custom guard driver. When you call Auth::guard('sanctum')->user() or rely on auth:sanctum middleware, Laravel's auth manager instantiates Sanctum's Guard class, which extends nothing from Laravel's built-in guards — it's a completely custom implementation of Illuminate\Contracts\Auth\Guard. This matters when you want to mix Sanctum with Passport or a JWT package: they do not share state.
Let me tell you about the migration that taught me this the hard way. In 2022, we had a fintech platform running Passport with full OAuth 2.0 — authorization codes, refresh tokens, the works. The team decided to simplify by moving to Sanctum. We swapped the guard, updated the middleware, and ran our test suite. Everything passed. We deployed to staging. Within 30 minutes, the mobile team reported that their app could not authenticate. The issue: Passport's guard stored the authenticated user in a different session namespace than Sanctum. Our test suite only tested one guard at a time — it never tested the transition. We had to write a bridge middleware that checked both guards during the migration window. That experience taught me to always test guard transitions in isolation, never assume they share state, and never deploy a guard change without a rollback plan.
<?php namespace App\IO\TheCodeForge\Auth; /** * FILE: Understanding Sanctum's request lifecycle * Run: php artisan tinker (paste each block to inspect) * * This isn't a controller — it's annotated pseudocode that mirrors * what Sanctum's Guard::user() does internally on every request. */ // ── Step 1: Sanctum checks if the request is "stateful" ────────────────────── // config/sanctum.php → 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', ...)) // Sanctum compares the request's Origin / Referer header against this list. $requestOrigin = 'https://my-spa.com'; // Comes from the browser request $statefulHosts = ['my-spa.com', 'localhost']; // From your .env / config $isStatefulRequest = in_array( parse_url($requestOrigin, PHP_URL_HOST), $statefulHosts ); if ($isStatefulRequest) { // ── Path A: SPA Session Auth ────────────────────────────────────────── // Sanctum delegates entirely to the 'web' guard. // The session cookie (laravel_session) carries the user ID. // No token is involved. CSRF protection via X-XSRF-TOKEN header. echo "Authenticating via session cookie (web guard delegation)" . PHP_EOL; } else { // ── Path B: Token Auth ──────────────────────────────────────────────── // Sanctum reads the raw token from the Authorization header. $rawToken = 'your-app|AbCdEf123456...'; // Format: {tokenId}|{rawSecret} // Splits on the pipe to get the token ID (avoids full-table scans). [$tokenId, $rawSecret] = explode('|', $rawToken, 2); // Hashes the secret portion with SHA-256. $hashedSecret = hash('sha256', $rawSecret); // Queries: SELECT * FROM personal_access_tokens WHERE id = ? AND token = ? // This single indexed query is why Sanctum performs well under load. echo "Looking up token ID: {$tokenId} with hash: {$hashedSecret}" . PHP_EOL; // If found, Sanctum calls $tokenModel->tokenable to get the User via // a polymorphic relationship — so one token table serves ALL model types. echo "User resolved via polymorphic tokenable() relationship" . PHP_EOL; }
Authenticating via session cookie (web guard delegation)
// Non-stateful request (mobile app / third-party):
Looking up token ID: 1 with hash: e3b0c44298fc1c149afb...
User resolved via polymorphic tokenable() relationship
{id}|{secret} format lets Sanctum fetch the token record by primary key first, then verify the hash — avoiding a full-table scan on the token column. If you ever see auth slow down under load, check whether you stripped the ID prefix before storing or forwarding the token. We had a mobile team that was splitting the token on the client side and only sending the secret portion. Every auth request did a full-table scan on a 4-million-row table. Login latency went from 12ms to 800ms. The fix was one line: stop stripping the ID.Complete Token Authentication Setup — Installation to Revocation
Let's build a real authentication flow: registration, login, token issuance with scoped abilities, protected routes, and clean logout with token revocation. This is the pattern you'd use for a mobile app or a CLI tool consuming your Laravel backend.
After composer require laravel/sanctum and php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider", run php artisan migrate to create the personal_access_tokens table. Add the HasApiTokens trait to your User model — that single trait is what gives Eloquent models the createToken(), , and tokens()currentAccessToken() methods.
The createToken() method accepts three arguments: a token name (for your own bookkeeping — show users which device has which token), an array of abilities, and an expiration via Illuminate\Support\Carbon. Abilities are the fine-grained permission strings Sanctum calls 'scopes' but internally stores as a JSON array. $token->can('read:invoices') is how you check them inside controllers — not Gate or Policy, just the token model itself.
Always revoke tokens on logout explicitly. If you just call Auth::logout() it only destroys the session — the personal_access_tokens row lives forever until manually deleted. That's a security hole in most tutorial code you'll find online.
One pattern I enforce on every team: per-device token naming. When a user logs in from 'iPhone 15 Pro', the token is named 'iPhone 15 Pro'. When they log in from 'Chrome on MacBook', that's a separate token with a different name. This means the user can see every active session in their account settings and revoke individual devices. I inherited a codebase once where every login created a token named 'api-token' — the user had 47 active tokens with no way to tell which device owned which one. We couldn attacker had issued one.
<?php namespace App\IO\TheCodeForge\Http\Controllers\Api; use App\IO\TheCodeForge\Http\Controllers\Controller; use App\IO\TheCodeForge\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; class ApiAuthController extends Controller { /** * Register a new user and immediately issue a scoped token. * Real-world: a mobile app's "Sign Up" screen calls this. */ public function register(Request $request): JsonResponse { $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'unique:users'], 'password' => ['required', 'string', 'min:12', 'confirmed'], 'device_name' => ['required', 'string', 'max:100'], // e.g. "iPhone 15 Pro" ]); $newUser = User::create([ 'name' => $validated['name'], 'email' => $validated['email'], // Always hash — never store plain text passwords 'password' => Hash::make($validated['password']), ]); // createToken() returns a NewAccessToken object. // The PLAIN TEXT token is only available RIGHT NOW via ->plainTextToken. // After this response, it cannot be retrieved again — store it client-side. $newAccessToken = $newUser->createToken( name: $validated['device_name'], // Human-readable label abilities: ['invoices:read', 'profile:update'], // Scope this token expiresAt: now()->addDays(30) // Expires in 30 days ); return response()->json([ 'user' => $newUser->only(['id', 'name', 'email']), // Send this ONCE. The client must store it securely (Keychain, Keystore) 'token' => $newAccessToken->plainTextToken, 'abilities' => $newAccessToken->accessToken->abilities, ], 201); } /** * Login: validate credentials, issue a new token per device. * Using per-device tokens means users can revoke individual sessions. */ public function login(Request $request): JsonResponse { $validated = $request->validate([ 'email' => ['required', 'email'], 'password' => ['required', 'string'], 'device_name' => ['required', 'string', 'max:100'], ]); $existingUser = User::where('email', $validated['email'])->first(); // Deliberate: we give the same error for 'user not found' AND 'wrong password'. // This prevents user enumeration attacks. if (! $existingUser || ! Hash::check($validated['password'], $existing in an hour),User->password)) { throw ValidationException::withMessages([ 'email' => ['The credentials you provided do not match our records.'], ]); } // Optional: revoke all tokens for this device name before issuing a new one. // Prevents token accumulation if users log in repeatedly on the same device. $existingUser->tokens() ->where('name', $validated['device_name']) ->delete(); $freshToken = $existingUser->createToken( name: $validated['device_name'], abilities: ['*'], // Wildcard = all abilities (use sparingly) ); return response()->json([ 'token' => $freshToken->plainTextToken, ]); } /** * Logout: revoke ONLY the token used for this request. * The user stays logged in on all other devices. */ public function logout(Request $request): JsonResponse { // currentAccessToken() returns the PersonalAccessToken model // that't even tell if an Sanctum authenticated this and sends it as X-XSRF-TOKEN on POST. await axios.get('/sanctum/csrf-cookie'); // Step 2: Post credentials to the standard web login route. // This sets the laravel_session cookie. await axios.post('/login', { email, password }); // Step 3: All subsequent requests are automatically authenticated. const profile = await axios.get('/api/user'); return profile.data; } */
// Set-Cookie: XSRF-TOKEN=eyJp...; Path=/; SameSite=Lax
// Set-Cookie: laravel_session=eyJp...; Path=/; HttpOnly; SameSite=Lax
// POST /login { email, password }
// HTTP 200 OK (session is now authenticated)
// GET /api/user (cookies sent automatically by browser)
// HTTP 200 OK
// { "id": 1, "name": "Alice Chen", "email": "alice@example.com" }
app.myapp.com and your API is at api.myapp.com, set SESSION_DOMAIN=.myapp.com in your .env (note the leading dot). This makes the session cookie available across all subdomains. Without the dot, api.myapp.com can't read the session cookie set by app.myapp.com and every authenticated request returns 401. This is the single most common subdomain-related Sanctum bug I see in the wild.Token Refresh and Rotation — The Pattern Sanctum Doesn't Ship
Sanctum does not have built-in refresh tokens. This is deliberate — Sanctum is designed to be simple, and refresh token flows add significant complexity. But in production, you often need tokens that expire quickly (for security) and can be renewed without forcing the user to re-enter their password.
Here is the pattern I use. It is not a full OAuth refresh token flow — it's simpler, and it works.
Issue short-lived access tokens (e.g., 1 hour) and a longer-lived refresh token (e.g., 30 days) in a single login response. The refresh token is stored in a separate database table — not in personal_access_tokens — with a one-to-one relationship to the access token. When the access token expires, the client sends the refresh token to a /refresh endpoint. The server validates the refresh token, deletes the old access token, issues a new access token pair, and returns the new tokens.
This gives you two security wins: short-lived access tokens limit the blast radius if one is stolen, and refresh token rotation means a stolen refresh token is detected and invalidated on its first fraudulent use.
<?php namespace App\IO\TheCodeForge\Http\Controllers\Api; use App\IO\TheCodeForge\Models\RefreshToken; use App\IO\TheCodeForge\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class TokenRefreshController extends Controller { public function refresh(Request $request): JsonResponse { $validated = $request->validate([ 'refresh_token' => ['required', 'string', 'size:64'], ]); $hashedToken = hash('sha256', $validated['refresh_token']); $refreshTokenRecord = RefreshToken::where('token', $hashedToken) ->where('expires_at', '>', now()) ->first(); if (! $refreshTokenRecord) { return response()->json(['message' => 'Invalid or expired refresh token.'], 401); } $user = $refreshTokenRecord->user; $oldAccessTokenId = $refreshTokenRecord->access_token_id; $refreshTokenRecord->delete(); $user->tokens()->where('id', $oldAccessTokenId)->delete(); $newAccessToken = $user->createToken( name: 'refreshed-session', abilities: ['*'], expiresAt: now()->addHour(), ); $newRawRefreshToken = bin2hex(random_bytes(32)); RefreshToken::create([ 'user_id' => $user->id, 'access_token_id' => $newAccessToken->accessToken->id, 'token' => hash('sha256', $newRawRefreshToken), 'expires_at' => now()->addDays(30), ]); return response()->json([ 'access_token' => $newAccessToken->plainTextToken, 'refresh_token' => $newRawRefreshToken, 'expires_in' => 3600, ]); } }
{
"access_token": "3|xYzAbCdEf1234567890abcdef",
"refresh_token": "f6e5d4c3b2a19087766554433221100ff",
"expires_in": 3600
}
Production Hardening — Token Pruning, Caching, Rate Limiting and Multi-Model Auth
Shipping Sanctum to production without hardening it is one of the most common oversights in Laravel applications. Three areas need your attention: expired token cleanup, query performance, and rate limiting.
Sanctum never auto-deletes expired tokens. They sit in personal_access_tokens forever, bloating the table and slowing down every auth query. Add php artisan sanctum:prune-expired --hours=24 to your scheduler in app/Console/Kernel.php. Run it daily. On a high-traffic app, this table can grow to millions of rows in weeks.
A client called us in 2023 because their login was taking 4 seconds. The personal_access_tokens table had 14 million rows — three years of tokens, never pruned. The SHA-256 index was still fast, but the polymorphic lookup on tokenable_type and tokenable_id was doing a full scan because there was no composite index on those columns. We pruned 13.8 million rows and added the composite index. Login dropped to 40ms. Four seconds to 40 milliseconds — from a missing cron job and a missing index.
For query performance, Sanctum's token column is already indexed in the migration, but the tokenable_type and tokenable_id columns (for polymorphic lookups) are not indexed by default in older Sanctum versions. If you authenticate multiple model types — User and Admin as separate Eloquent models — add a composite index on those two columns manually.
Multi-model authentication is a legitimate pattern: separate users and admin_users tables, each with their own token namespace. Both models use HasApiTokens. Both share the same personal_access_tokens table, differentiated by tokenable_type. In your route definitions, use separate guards or check $request->user() instanceof checks to enforce boundaries. Don't assume the authenticated tokenable is always a User.
For rate limiting, protect your auth endpoints aggressively. Login and register should be limited to 5 requests per minute per IP. The refresh endpoint (if you implemented the pattern above) should be limited to 10 per minute. Never leave auth endpoints without rate limiting — it's the equivalent of leaving your front door unlocked.
<?php namespace App\IO\TheCodeForge\Console; // ── FILE 1: app/Console/Kernel.php — Scheduled token pruning ───────────────── use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; class Kernel extends ConsoleKernel { protected function schedule(Schedule $schedule): void { // Prune tokens that expired more than 24 hours ago. // Runs every day at 3:00 AM — low traffic window. $schedule->command('sanctum:prune-expired --hours=24') ->dailyAt('03:00') ->onOneServer() // Prevents duplicate runs in multi-server setups ->runInBackground(); // Doesn't block other scheduled tasks // Also prune refresh tokens if you implemented the refresh pattern $schedule->call(function () { \App\IO\TheCodeForge\Models\RefreshToken::where('expires_at', '<', now()) ->delete(); })->dailyAt('03:15'); } } // ── FILE 2: Token lookup caching (performance optimization) ─────────────────── namespace App\IO\TheCodeForge\Extensions; use Illuminate\Support\Facades\Cache; use Laravel\Sanctum\PersonalAccessToken; class CachedPersonalAccessToken extends PersonalAccessToken { /** * Override Sanctum's token finder to cache the result. * Laravel Sanctum calls PersonalAccessToken::findToken($rawToken) internally. * By extending the model and registering it, we inject caching transparently. */ public static function findToken(string $token): ?static { if (! str_contains($token, '|')) { return parent::findToken($token); } [$tokenId] = explode('|', $token, 2); $cacheKey = "sanctum_token_{$tokenId}"; // Cache for 5 minutes. Short enough that revoked tokens expire quickly. // After logout (token deletion), the cached record will cause a 401 // only if someone uses the revoked token within the 5-min window — // acceptable trade-off for most apps. Use 0 TTL for banking/medical. return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($token) { return parent::findToken($token); }); } /** * When a token is deleted (logout), bust the cache immediately. */ protected static function boot(): void { parent::boot(); static::deleted(function (self $accessToken) { Cache::forget("sanctum_token_{$accessToken->id}"); }); } } // ── FILE 3: AppServiceProvider.php — Tell Sanctum to use your cached model ──── namespace App\IO\TheCodeForge\Providers; use Laravel\Sanctum\Sanctum; class AppServiceProvider extends ServiceProvider { public function boot(): void { Sanctum::usePersonalAccessTokenModel( \App\IO\TheCodeForge\Extensions\CachedPersonalAccessToken::class ); } } // ── FILE 4: Database migration — Add missing index for multi-model auth ─────── use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { never Schema::table('personal_access_tokens', function (Blueprint $table) { $table->index( ['tokenable_type', 'tokenable_id'], 'pat_tokenable_type_tokenable_id_index' ); }); } public function down(): void { Schema::table('personal_access_tokens', function (Blueprint $table) { $table->dropIndex('pat_tokenable_type_tokenable_id_index'); }); } }; // ── FILE 5: routes/api.php — Rate limiting auth endpoints ───────────────────── use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('api-auth', function (Request $request) { return Limit::perMinute(5) ->by($request->ip()) ->response(function () { return response()->json( ['message' => 'Too many login attempts. Try again in 60 seconds.'], 429 ); }); }); Route::middleware('throttle:api-auth')->group(function () { Route::post('/login', [ApiAuthController::class, 'login']); Route::post('/register', [ApiAuthController::class, 'register']); });
[2024-01-15 03:00:01] Pruned 1,847 expired Sanctum tokens.
// GET /api/user (with caching):
// First request: ~8ms (DB query)
// Requests 2-N: ~1ms (Cache hit for 5 minutes)
// POST /api/login (6th attempt in 60 seconds):
// HTTP 429 Too Many Requests
// { "message": "Too many login attempts. Try again in 60 seconds." }
Cache::tags(['user_tokens', "user_{$userId}"])->flush() on revocation — to immediately bust all tokens for a specific user. For fintech or healthcare applications, I recommend zero caching on tokens. The 1ms savings is not worth the security trade-off when money or medical data is involved.Testing Sanctum Auth Flows — PHPUnit Patterns That Actually Work
Testing API authentication is where most Laravel test suites fall apart. The standard actingAs() helper works for the web guard but does not automatically configure Sanctum's token-based auth. You need actingAs() with a second argument specifying the guard, or you need to create a real token and send it in the Authorization header.
I use three testing patterns depending on what I'm testing. Pattern one: mock auth for unit tests where I just need $request->user() to return a user. Pattern two: real token creation for integration tests where I need to verify token abilities, expiration, and revocation. Pattern three: full HTTP tests with the Sanctum middleware stack for end-to-end API tests.
The mistake I see most often: developers use actingAs($user) (which defaults to the web guard) in their API tests, and the tests pass — but in production, the sanctum guard is used. The tests give false confidence because they exercise to use Auth::guard('sanctum')->user(). The user is not available because there is no request.
The pattern I use: pass the user ID (or token ID) as a constructor argument to the job, resolve the user from the database inside the job's method, and never rely on the auth guard in non-HTTP contexts. If you need to perform actions 'on behalf of' a user in a job, store the user ID in the job payload.handle()
For Artisan commands that need to simulate a user context (e.g., a maintenance command that processes data for a specific tenant), accept a --user-id option and resolve the user manually. Do not try to 'log in' via Sanctum in a CLI context.
One edge case I ran into: a notification system that sent emails 'from' the authenticated user. The notification was dispatched from a controller (where the user was available) but queued for delivery. By the time the job executed, the auth context was gone. The email was sent from 'System' instead of the user's name. The fix was to pass the user ID as a job property and resolve it in the job's handle method.
<?php namespace App\IO\TheCodeForge\Jobs; use App\IO\TheCodeForge\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class ProcessInvoiceExport implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * Store the user ID — NOT the user model (serialisation overhead) * and NOT a token (tokens don't work in non-HTTP contexts). */ public function __construct( private readonly int $userId, private readonly int $invoiceId, ) {} public function handle(): void { // Resolve the user from the database. // This is your 'auth context' inside the job. $user = User::findOrFail($this->userId); // Now you have a fully hydrated user model. // Perform the action on their behalf. $invoice = $user->invoices()->findOrFail($this->invoiceId); // Export logic... logger()->info('Invoice exported', [ 'user_id' => $user->id, 'invoice_id' => $invoice->id, 'context' => 'queue_job', // Not HTTP — no Sanctum guard ]); } } // ── Dispatching from a controller (where auth IS available) ─────────────────── // public function export(Request $request, Invoice $invoice): JsonResponse // { // // Capture the authenticated user ID NOW, while the request context exists. // ProcessInvoiceExport::dispatch( // userId: $request->user()->id, // invoiceId: $invoice->id, // ); // // return response()->json(['message' => 'Export queued.']); // } // ── Artisan command with manual user resolution ─────────────────────────────── namespace App\IO\TheCodeForge\Console\Commands; use App\IO\TheCodeForge\Models\User; use Illuminate\Console\Command; class CleanupUserTokens extends Command { protected $signature = 'tokens:cleanup {--user-id= : Only clean tokens for this user} {--older-than-days=30 : Delete tokens older than N days}'; protected $description = 'Clean up old Sanctum tokens, optionally scoped to a user'; public function handle(): int { $userId = $this->option('user-id'); $days = (int) $this->option('older-than-days'); // No Sanctum guard here — resolve the user manually if needed if ($userId) { $user = User::findOrFail($userId); $deleted = $user->tokens() ->where('created_at', '<4 hours or', now()->subDays($days)) ->delete(); $this->info("Deleted {$deleted} tokens for user {$user->email}."); } else { $deleted = \Laravel\Sanctum\PersonalAccessToken::where( 'created_at', '<', now()->subDays($days) )->delete(); $this->info("Deleted {$deleted} tokens globally."); } return Command::SUCCESS; } }
// Deleted 12,847 tokens globally.
// php artisan tokens:cleanup --user-id=42 --older-than-days=30
// Deleted 3 tokens for user alice@example.com.
Sanctum vs JWT vs Passport — Choosing the Right Auth Package
This is the question every Laravel developer asks at the start of a new project, and the answer is simpler than most articles make it.
Sanctum is for first-party applications — your own SPA, your own mobile app, your own CLI tools. It stores tokens in your database, supports session-based auth for SPAs, and has a minimal feature set. If you control both the frontend and the backend, Sanctum is almost always the right choice.
Passport is for third-party API access — when external developers need to build applications against your API. It implements a full OAuth 2.0 server with authorization codes, client credentials, refresh tokens, and scopes. If you are building a platform where other companies integrate with your API (think Stripe, GitHub, or any API marketplace), you need Passport.
JWT packages (like tymondesigns/jwt-auth) are stateless — the token contains the user data and is verified with a secret key, no database lookup required. This means zero DB queries per auth request, which is great for microservices. But it also means you cannot revoke a token until it expires. If a token is stolen, you have to wait for its expiration or implement a token blacklist (which negates the stateless advantage).
My recommendation: start with Sanctum. If you later need OAuth 2.0 grant types, migrate to Passport. If you need truly stateless auth across microservices, consider JWT — but know the revocation trade-off. I have never seen a Laravel application that started with Passport and actually needed it. I have seen dozens that started with JWT and later wished they had token revocation.
One experience worth sharing: we built a microservices architecture in 2021 with JWT auth across six services. It was fast — zero DB queries per request. Three months in, a developer's laptop was stolen with a valid JWT stored in a .env file. The token had a 24-hour expiration. We had to wait 2 deploy to Sanctum with database-backed tokens. Yes, it added one DB query per request. But we could revoke a single token in milliseconds. The trade-off was worth it.
<?php namespace App\IO\TheCodeForge\Auth; /** * Decision matrix for choosing an auth package. * Not executable code — this is a reference cheat sheet. */ // ── SCENARIO 1: Your own SPA + Mobile app ──────────────────────────────────── // Choice: SANCTUM // Why: Session auth for SPA (no token in JS), token auth for mobile. // One package, two mechanisms, shared user table. // Setup time: 30 minutes. // ── SCENARIO 2: Third-party developers building against your API ────────────── // Choice: PASSPORT // Why: OAuth 2.0 authorization codes, client registration, scoped access. // Third parties need to go through an approval flow. // Setup time: 2-4 hours. // ── SCENARIO 3: Microservices with inter-service auth ───────────────────────── // Choice: JWT (tymondesigns/jwt-auth) or Sanctum with caching // Why: JWT is stateless — no DB hit per request. // Sanctum + cache achieves similar performance with revocation. // Trade-off: JWT tokens can't be revoked until expiry. // ── SCENARIO 4: Internal admin panel + public API ───────────────────────────── // Choice: SANCTUM with multi-model auth // Why: Admin tokens and user tokens share the same table. // Different abilities enforce different access levels. // No need for separate auth systems. // ── SCENARIO 5: API marketplace (like RapidAPI) ────────────────────────────── // Choice: PASSPORT // Why: You need client registration, API key management, usage quotas, // and authorization code flows for web-based integrations. // Setup time: 1-2 days including UI for client management. // ── Performance comparison (1000 req/s, single server) ──────────────────────── // Sanctum (token, no cache): ~1000 DB queries/s for auth (indexed, fast) // Sanctum (token, cached): ~200 DB queries/s (5-min TTL) // JWT: 0 DB queries/s (verified with secret key) // Passport: ~1000 DB queries/s + OAuth validation overhead // // At 1000 req/s, the difference between Sanctum and JWT is ~2ms per request. // Unless you're serving 10,000+ req/s, this is not your bottleneck. // Choose based on features, not micro-benchmarks.
auth:sanctum for your SPA and mobile app, and auth:api (JWT) for inter-service communication. They share the same User model but use different resolution logic. The key is to never mix them on the same route — each route should use exactly one guard. We run this hybrid setup on a platform with 6 microservices: Sanctum for the user-facing API, JWT for service-to-service calls. It works, but it doubles your auth testing surface.| Feature / Aspect | Sanctum Token Auth | Sanctum SPA Cookie Auth |
|---|---|---|
| Best for | Mobile apps, CLI tools, third-party API clients | Your own first-party SPA on the same or subdomain |
| Token storage | Client stores plain token (Keychain, Keystore, env var) | Browser manages HttpOnly session cookie — JS never sees it |
| XSS risk | High if stored in localStorage | Low — HttpOnly cookie is not accessible to JavaScript |
| CSRF risk | None (no cookies) | Must validate X-XSRF-TOKEN header on every state-changing request |
| Cross-origin support | Any origin — just send Bearer token | Must configure CORS + SANCTUM_STATEFUL_DOMAINS precisely |
| Token abilities / scopes | Yes — per-token ability strings | No — the session authenticates the full user, not a scoped token |
| Revocation granularity | Per-device, per-ability, or all tokens | Single logout destroys the session |
| DB queries per request | 1 (token lookup by ID + hash verify) | 0 (session is in cache/file, Sanctum delegates to web guard) |
| Works without JavaScript | Yes (curl, Postman, etc.) | No — requires cookie-aware HTTP client |
| Production complexity | Low | Medium (CORS, session domain, CSRF all must align) |
| Token refresh support | Not built-in — implement custom refresh token pattern | N/A — session is renewed on each request automatically |
| Works in queue jobs | No — pass user ID as job data, resolve manually | No — same approach, pass user ID as job data |
🎯 Key Takeaways
- Sanctum is two auth systems in one package: opaque token auth for API clients and cookie/session auth for SPAs — they share a guard but use completely different resolution paths and should never be confused with each other.
- The plain-text token from createToken()->plainTextToken is a one-time read: Sanctum stores only the SHA-256 hash. Losing it means issuing a new token, not recovering the old one.
- SANCTUM_STATEFUL_DOMAINS and CORS supports_credentials must always be configured together for SPA auth — getting one right and missing the other is the #1 cause of phantom 401 errors in Laravel SPA setups.
- Personal access tokens never self-delete. Schedule sanctum:prune-expired daily in production — without it your token table will bloat silently, degrading auth query performance over time.
- Sanctum does not have built-in refresh tokens. Implement a custom refresh token rotation pattern with separate storage, short-lived access tokens (1 hour), and long-lived refresh tokens (30 days) for production mobile apps.
- Token abilities are not automatically integrated with Laravel Gates or Policies. Wire them together with a Gate::before callback that checks tokenCan() as a fallback before Policy evaluation.
- Always test with Sanctum::actingAs($user), not the bare actingAs($user) which defaults to the 'web' guard. Tests that use the wrong guard give false confidence.
- In non-HTTP contexts (queue jobs, Artisan commands), the Sanctum guard is not available. Pass the user ID as job data and resolve the user from the database inside the job's
handle()method. - Choose Sanctum for first-party apps, Passport for third-party OAuth 2.0, and JWT only when you need truly stateless microservice auth and accept the revocation trade-off.
- Rate-limit all auth endpoints aggressively — login (5/min), register (3/min), refresh (10/min). Unprotected auth endpoints are the equivalent of leaving your front door unlocked.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QSanctum supports two distinct authentication mechanisms. Can you walk through exactly how each one works at the HTTP level and explain when you'd choose one over the other?
- QIf a user reports they were logged out on Device A when they logged out on Device B, how would you diagnose this? What does the correct implementation look like in Sanctum to support independent per-device sessions?
- QYou're seeing intermittent 401 responses on a SPA that uses Sanctum cookie auth. The user is definitely logged in — you can see the session in the database. What are the first three things you check, and why?
- QExplain how Sanctum's token abilities work. How would you integrate them with Laravel's Gate and Policy system so that a single
authorize()call respects both token scopes and resource-level permissions? - QSanctum does not have built-in refresh tokens. Walk me through how you would implement a secure refresh token rotation pattern. What are the security trade-offs, and how do you detect token theft?
- QA developer on your team used actingAs($user) in all their API tests. The tests pass but production auth is broken. Explain what happened and how to fix both the tests and the production issue.
- QYou need to authenticate both regular users and admin users with Sanctum, using separate tables. How would you set this up, and what database index do you need to add for performance?
- QYour personal_access_tokens table has 14 million rows and login is taking 4 seconds. Walk me through your diagnosis and remediation plan.
Frequently Asked Questions
What is the difference between Laravel Sanctum and Laravel Passport?
Sanctum issues simple opaque tokens stored in your own database and supports first-party SPA session auth. Passport implements a full OAuth 2.0 authorization server with authorization codes, refresh tokens, and client credentials — the kind of infrastructure you need when third-party developers need to build apps against your API. If you're building your own SPA or mobile app, Sanctum is almost always the right choice. Reach for Passport only when you need OAuth 2.0 grant types.
What is the difference between Laravel Sanctum and JWT?
Sanctum stores token hashes in your database and verifies them with a single indexed query per request. JWT packages like tymondesigns/jwt-auth verify tokens cryptographically with a secret key — zero database queries, but tokens cannot be revoked until they expire. Use Sanctum when you need token revocation (logout, compromised accounts). Use JWT when you need truly stateless auth across microservices and accept that stolen tokens are valid until expiry.
How do I make Sanctum tokens expire automatically?
Set the expiration key in config/sanctum.php to the number of minutes you want tokens to live, or use the third argument to createToken(): createToken('device', ['*'], . Sanctum checks the now()->addDays(30))expires_at column on every token lookup and treats expired tokens as invalid. Remember to also schedule php artisan sanctum:prune-expired to physically remove expired rows from the database.
Can I use Sanctum to authenticate both a User model and an Admin model with separate token namespaces?
Yes. Add the HasApiTokens trait to both models. Sanctum's personal_access_tokens table uses a polymorphic tokenable_type and tokenable_id column pair, so tokens for App\Models\User and App\Models\Admin are stored in the same table but are fully isolated by their tokenable type. After authentication, use $request->user() instanceof Admin checks to enforce access boundaries in your controllers. Add a composite database index on (tokenable_type, tokenable_id) for performance if you have multiple tokenable models.
How do I implement refresh tokens with Sanctum?
Sanctum does not have built-in refresh tokens. Implement a custom pattern: issue short-lived access tokens (1 hour) and long-lived refresh tokens (30 days) stored in a separate table. When the access token expires, the client sends the refresh token to a /refresh endpoint. The server validates it, deletes the old token pair, issues a new pair, and returns the new tokens. Implement token rotation — each refresh invalidates the previous refresh token — so stolen refresh tokens are detected on next use.
Why do my SPA requests always return 401 even though the user is logged in?
The most common cause is a missing or misconfigured SANCTUM_STATEFUL_DOMAINS. Sanctum compares the request Origin header against this list. If there is no match, it treats the request as non-stateful and never checks the session cookie. Other causes: EnsureFrontendRequestsAreStateful middleware is not in the api middleware group, CORS supports_credentials is not true, the frontend is not sending withCredentials: true, or SESSION_DOMAIN is not set correctly for subdomain setups.
How do I test Sanctum-authenticated endpoints in PHPUnit?
Use Sanctum::actingAs($user) — not the bare actingAs($user) which defaults to the web table** — login guard. For ability testing, pass abilities as the second argument: Sanctum::actingAs($user, ['invoices:read']). For full integration tests, create a real token with $user->createToken(), extract the plainTextToken, and send it in the Authorization header. For SPA auth tests, call /sanctum/csrf-cookie, extract the XSRF-TOKEN cookie, and send it as an X-XSRF-TOKEN header on subsequent requests.
Can I use Sanctum in queue jobs or Artisan commands?
No. Sanctum's guard is designed for HTTP requests. In non-HTTP contexts, there is no Authorization header or session cookie, so the guard always returns null. Instead, pass the user ID as a constructor argument to the job, resolve the user from the database inside the job's handle() method, and perform actions on their behalf using the resolved User model. Never try to authenticate via Sanctum in a CLI context.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.