Laravel Sanctum API Authentication: Tokens, SPA Sessions & Production Secrets
Every production API eventually hits the same crossroads: how do you prove a request comes from a legitimate user without forcing them to send their password on every single call? The naive answer — embed credentials in every request — is a security disaster. The overcomplicated answer — stand up a full OAuth 2.0 authorization server — is overkill for the vast majority of Laravel applications. Sanctum lives in the sweet spot: lightweight enough to ship in an afternoon, powerful enough to handle real-world auth for SPAs, mobile clients, and third-party API consumers simultaneously.
Sanctum solves two distinct problems that developers often conflate. First, it issues opaque personal access tokens stored in your own database, letting mobile apps and CLI tools authenticate without a browser. Second, it authenticates your own first-party SPA using Laravel's battle-tested cookie and session infrastructure — no token exposed in JavaScript memory at all. Understanding that Sanctum has two completely separate authentication drivers under the hood is the key insight most tutorials skip, and it's the source of 80% of the 'why isn't this working?' questions on Stack Overflow.
By the end of this article 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, 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.
<?php /** * 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
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(), tokens(), and 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.
<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\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'], $existingUser->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 Sanctum authenticated this specific request with. $request->user()->currentAccessToken()->delete(); return response()->json([ 'message' => 'This device has been logged out successfully.', ]); } /** * Logout from ALL devices — nuclear option. * Use for 'I lost my phone' or 'Suspect unauthorized access' scenarios. */ public function logoutAllDevices(Request $request): JsonResponse { // tokens() is the HasMany relationship to personal_access_tokens. // This deletes every row belonging to this user. $revokedCount = $request->user()->tokens()->delete(); return response()->json([ 'message' => "Logged out from {$revokedCount} device(s).", ]); } } // ── routes/api.php ──────────────────────────────────────────────────────────── // Route::post('/register', [ApiAuthController::class, 'register']); // Route::post('/login', [ApiAuthController::class, 'login']); // Route::middleware('auth:sanctum')->group(function () { // Route::post('/logout', [ApiAuthController::class, 'logout']); // Route::post('/logout-all', [ApiAuthController::class, 'logoutAllDevices']); // Route::get('/profile', [ProfileController::class, 'show']); // }); // ── How to check token abilities inside a protected controller ──────────────── // public function show(Request $request): JsonResponse // { // // Throws 403 automatically if the token lacks this ability // $request->user()->tokenCan('invoices:read') || abort(403, 'Token cannot read invoices'); // // // Or use the ability middleware on the route: // // Route::get('/invoices', ...)->middleware('ability:invoices:read'); // }
{
"user": { "id": 1, "name": "Alice Chen", "email": "alice@example.com" },
"token": "1|kLmNoPqRsTuVwXyZ1234567890abcdef",
"abilities": ["invoices:read", "profile:update"]
}
// POST /api/login
{ "token": "2|aAbBcCdDeEfFgGhHiIjJkK1234567890" }
// POST /api/logout (Authorization: Bearer 2|aAbBcC...)
{ "message": "This device has been logged out successfully." }
// POST /api/logout-all
{ "message": "Logged out from 3 device(s)." }
SPA Cookie Auth — CSRF, CORS, and the Stateful Domain Config Trap
Cookie-based auth for SPAs is Sanctum's most misunderstood feature. The flow is: your SPA calls /sanctum/csrf-cookie to receive a session cookie and an XSRF-TOKEN cookie, then posts credentials to /login (a standard web route, not an API route), and from that point forward every request from the browser automatically carries the laravel_session cookie. No token in JavaScript memory. This is meaningfully more secure against XSS attacks than storing a Bearer token in localStorage.
The configuration that trips up almost everyone is the SANCTUM_STATEFUL_DOMAINS environment variable. It must list every domain your SPA is served from, including the port in development (localhost:3000, localhost:5173). Sanctum compares the Origin or Referer request header against this list. If there's no match, Sanctum treats the request as non-stateful and never checks the session — your authenticated user comes back as null and you get a mysterious 401.
Your API must also be configured for CORS via config/cors.php. The critical settings are supports_credentials: true and the correct allowed_origins. On the frontend, your HTTP client must send withCredentials: true (Axios) or credentials: 'include' (fetch). Miss either side of that handshake and cookies won't flow. These two configs — Sanctum stateful domains and CORS credentials — must always be set together.
<?php // ── FILE 1: config/sanctum.php (key section) ───────────────────────────────── return [ /* * Stateful domains: requests from these origins authenticate via session. * Add EVERY domain your SPA can be served from, including ports. * Production: 'my-spa.com' * Development: 'localhost:3000', 'localhost:5173' (Vite default) * * In .env: SANCTUM_STATEFUL_DOMAINS=my-spa.com,localhost:3000 */ 'stateful' => explode(',', env( 'SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', 'localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000', Sanctum::currentApplicationUrlWithPort() ) )), /* * Token expiration: null means tokens live forever. * In production, always set this. 525600 = 1 year in minutes. * Expired tokens are NOT auto-deleted — run the pruning command. */ 'expiration' => env('SANCTUM_TOKEN_EXPIRY_MINUTES', null), /* * Guard: which auth guard Sanctum will try for stateful sessions. * Keep this as 'web' unless you have a custom session guard. */ 'guard' => ['web'], 'middleware' => [ // This middleware must be registered for SPA auth to work. // It initializes the session and validates the CSRF token. 'authenticate_session' => Laravel\Sanctum\Http\Middleware\CheckAbilities::class, 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, ], ]; // ── FILE 2: config/cors.php ─────────────────────────────────────────────────── return [ 'paths' => [ 'api/*', 'sanctum/csrf-cookie', // MUST include this — it seeds the XSRF-TOKEN cookie 'login', 'logout', ], 'allowed_methods' => ['*'], 'allowed_origins' => [ env('FRONTEND_URL', 'http://localhost:3000'), // Never use '*' here — browsers won't send credentials with wildcard origins ], 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, /* * THIS IS THE CRITICAL SETTING. * 'supports_credentials' => true tells the browser: * "yes, include cookies and auth headers in cross-origin requests". * Your frontend ALSO needs withCredentials: true on every request. */ 'supports_credentials' => true, ]; // ── FILE 3: app/Http/Kernel.php — Register Sanctum's stateful middleware ───── // Inside the 'api' middleware group: protected $middlewareGroups = [ 'api' => [ // This middleware intercepts stateful requests BEFORE auth runs. // Without it, cookie auth on the 'api' group is silently broken. \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, 'throttle:api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], ]; // ── FILE 4: Frontend (JavaScript / Axios) ───────────────────────────────────── /* import axios from 'axios'; // Configure Axios ONCE at app startup — every request will carry cookies. axios.defaults.baseURL = 'https://api.myapp.com'; axios.defaults.withCredentials = true; // <── non-negotiable for SPA cookie auth async function loginUser(email, password) { // Step 1: Fetch the CSRF cookie. Sanctum sets XSRF-TOKEN cookie. // Axios automatically reads this cookie 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" }
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.
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.
<?php // ── FILE 1: app/Console/Kernel.php — Scheduled token pruning ───────────────── namespace App\Console; 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 } } // ── FILE 2: Token lookup caching (performance optimization) ─────────────────── // By default, every authenticated request hits personal_access_tokens. // At 1000 req/s this is 1000 DB queries/s just for auth. // Solution: Cache the token model for the duration of the request — or longer. // In a Service Provider or custom Guard extension: namespace App\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, '|')) { // Tokens without the pipe are legacy plain hashes — don't cache them return parent::findToken($token); } [$tokenId] = explode('|', $token, 2); // Cache key is based on token ID — safe because we still hash-verify below. $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 ──── use Laravel\Sanctum\Sanctum; public function boot(): void { // One line — Sanctum will now call CachedPersonalAccessToken::findToken() Sanctum::usePersonalAccessTokenModel( \App\Extensions\CachedPersonalAccessToken::class ); } // ── FILE 4: Database migration — Add missing index for multi-model auth ─────── // php artisan make:migration add_tokenable_index_to_personal_access_tokens use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::table('personal_access_tokens', function (Blueprint $table) { // Composite index on the polymorphic columns. // Critical when you have multiple tokenable model types. // Without this, every auth query does a full-table scan on tokenable columns. $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 ───────────────────── // Register a named limiter in RouteServiceProvider or a Service Provider: use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('api-auth', function (Request $request) { // Throttle login attempts: 5 per minute per IP address. // Brute-force protection without a full auth shield package. return Limit::perMinute(5) ->by($request->ip()) ->response(function () { return response()->json( ['message' => 'Too many login attempts. Try again in 60 seconds.'], 429 ); }); }); // Apply the limiter to auth routes: 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." }
| 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) |
🎯 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Not adding EnsureFrontendRequestsAreStateful to the api middleware group — Symptom: SPA requests always return 401 even though the user just logged in. The session cookie is sent but Sanctum never reads it because the stateful middleware never ran. Fix: Add \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class as the FIRST item in the 'api' middleware group in app/Http/Kernel.php.
- ✕Mistake 2: Storing the token in localStorage instead of a secure store — Symptom: No immediate error, but any XSS injection on your frontend can steal the token with a single document.cookie or localStorage.getItem() call. Fix: For mobile apps, use the OS Keychain (iOS) or Keystore (Android). For browser-based clients you control, switch to SPA cookie auth. For third-party browser clients that must use tokens, store in memory (a JS variable) and accept the trade-off.
- ✕Mistake 3: Forgetting to call sanctum:prune-expired on a schedule — Symptom: The personal_access_tokens table silently grows to millions of rows over months. Auth queries get progressively slower. A SHOW TABLE STATUS reveals a 2GB table. Fix: Schedule php artisan sanctum:prune-expired --hours=24 daily and retroactively clean the table with DELETE FROM personal_access_tokens WHERE expires_at < NOW() AND expires_at IS NOT NULL — run in batches of 10,000 rows on a live production database to avoid table locks.
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?
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.
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', ['*'], now()->addDays(30)). Sanctum checks the 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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.