Skip to content
Home PHP Laravel Sanctum API Authentication: Tokens, SPA Sessions & Production Secrets

Laravel Sanctum API Authentication: Tokens, SPA Sessions & Production Secrets

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Laravel → Topic 14 of 15
Laravel Sanctum API authentication explained in depth — token abilities, SPA cookie sessions, CSRF pitfalls, multi-guard setups, token refresh patterns, testing, and production gotchas you won't find elsewhere.
🔥 Advanced — solid PHP foundation required
In this tutorial, you'll learn
Laravel Sanctum API authentication explained in depth — token abilities, SPA cookie sessions, CSRF pitfalls, multi-guard setups, token refresh patterns, testing, and production gotchas you won't find elsewhere.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer

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.

SanctumBootDiagram.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
<?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;
}
▶ Output
// Stateful request (SPA on trusted domain):
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
🔥Why the pipe delimiter in the raw token matters:
The {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(), 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.

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.

ApiAuthController.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
<?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;
}
*/
▶ Output
// GET /sanctum/csrf-cookie → HTTP 204 No Content
// 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" }
💡Pro Tip: Subdomain SPA and API on the same domain
If your SPA is at 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.

TokenRefreshController.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
<?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,
        ]);
    }
}
▶ Output
// POST /api/auth/refresh { "refresh_token": "a1b2c3d4e5f6..." }
{
"access_token": "3|xYzAbCdEf1234567890abcdef",
"refresh_token": "f6e5d4c3b2a19087766554433221100ff",
"expires_in": 3600
}
⚠ Watch Out: Refresh token rotation means old tokens are immediately invalid
Every refresh call destroys the previous refresh token. If your mobile app has a race condition — two API calls both detect an expired access token and both try to refresh simultaneously — the second refresh will fail because the first already consumed and deleted the refresh token. Handle this on the client by queuing refresh requests: if a refresh is already in-flight, wait for it instead of starting a second one. We had this exact race condition in a React Native app. The user would get logged out randomly when two background API calls expired at the same moment.

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.

ProductionHardening.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
<?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']);
});
▶ Output
// scheduler output (daily at 03:00):
[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." }
⚠ Watch Out: Caching tokens breaks instant revocation
If you cache tokens and a user's account is compromised, an admin-triggered token revocation won't take effect until the cache TTL expires. For security-critical apps, either skip token caching or use a cache tag strategy — 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 handle() 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.

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.

SanctumNonHttpContexts.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
<?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 IDNOT 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;
    }
}
▶ Output
// php artisan tokens:cleanup --older-than-days=90
// Deleted 12,847 tokens globally.

// php artisan tokens:cleanup --user-id=42 --older-than-days=30
// Deleted 3 tokens for user alice@example.com.
🔥Rule of thumb: Auth is a request-layer concern
Never try to make Sanctum work in queue jobs or CLI commands. Instead, capture the user ID in the request context (controller), pass it to the job/command as data, and resolve the user from the database inside the job. The auth guard is for HTTP. Everything else is just Eloquent.

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.

AuthPackageComparison.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
<?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.
💡Pro Tip: You can mix Sanctum and JWT if you must
Laravel's guard system lets you register multiple guards. You can have 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 / AspectSanctum Token AuthSanctum SPA Cookie Auth
Best forMobile apps, CLI tools, third-party API clientsYour own first-party SPA on the same or subdomain
Token storageClient stores plain token (Keychain, Keystore, env var)Browser manages HttpOnly session cookie — JS never sees it
XSS riskHigh if stored in localStorageLow — HttpOnly cookie is not accessible to JavaScript
CSRF riskNone (no cookies)Must validate X-XSRF-TOKEN header on every state-changing request
Cross-origin supportAny origin — just send Bearer tokenMust configure CORS + SANCTUM_STATEFUL_DOMAINS precisely
Token abilities / scopesYes — per-token ability stringsNo — the session authenticates the full user, not a scoped token
Revocation granularityPer-device, per-ability, or all tokensSingle logout destroys the session
DB queries per request1 (token lookup by ID + hash verify)0 (session is in cache/file, Sanctum delegates to web guard)
Works without JavaScriptYes (curl, Postman, etc.)No — requires cookie-aware HTTP client
Production complexityLowMedium (CORS, session domain, CSRF all must align)
Token refresh supportNot built-in — implement custom refresh token patternN/A — session is renewed on each request automatically
Works in queue jobsNo — pass user ID as job data, resolve manuallyNo — 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

    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. I have seen this exact bug in at least five production deployments.

    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 localStorage.getItem() call. We had a security audit where the auditor injected a single <img> tag into a comment field. The XSS payload read the Bearer token from localStorage and exfiltrated it to an external server. The entire user account was compromised in under 3 seconds.

    Fix

    For mobile apps, use the OS Keychain (iOS) or Keystore (Android). For browser-based clients you control, switch to SPA cookie auth.

    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.

    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.

    Using actingAs($user) in API tests instead of Sanctum::actingAs($user)
    Symptom

    Tests pass locally but production auth fails. The default actingAs uses the 'web' guard, not the 'sanctum' guard. Your tests never exercised the actual auth middleware.

    Fix

    Always use Sanctum::actingAs($user) or actingAs($user, 'sanctum'). Add a PHPUnit listener that fails tests using bare actingAs() without a guard argument.

    Not configuring SESSION_DOMAIN for subdomain setups
    Symptom

    SPA at app.myapp.com can authenticate, but API at api.myapp.com returns 401. The session cookie set by the SPA subdomain is not visible to the API subdomain.

    Fix

    Set SESSION_DOMAIN=.myapp.com (with leading dot) in your .env. This is the single most common subdomain-related Sanctum bug.

    Not rate-limiting auth endpoints
    Symptom

    Brute-force login attacks succeed because there's nothing stopping an attacker from trying 10,000 passwords per minute.

    Fix

    Add throttle middleware to /login and /register routes. Use 5 requests per minute per IP for login, 3 per minute for registration.

    Calling Auth::logout() and assuming the token is revoked
    Symptom

    User logs out but their token still works for API requests. Auth::logout() only destroys the session — the personal_access_tokens row persists.

    Fix

    Always call $request->user()->currentAccessToken()->delete() on logout to explicitly revoke the token.

    Stripping the token ID prefix before sending or storing
    Symptom

    Auth requests become slow under load because Sanctum can't use the primary key lookup and falls back to a full-table scan on the token hash column.

    Fix

    before sending or storing — Symptom: Auth requests become slow under load because Sanctum can't use the primary key lookup and falls back to a full-table scan on the token hash column. Fix: Always store and send the full token string including the ID prefix (format: {id}|{secret}). We had a mobile team strip the prefix and login latency went from 12ms to 800ms on a 4-million-row table.

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', ['*'], 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.

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.

🔥
Naren Founder & Author

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.

← PreviousLaravel Testing with PHPUnitNext →Laravel Broadcasting
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged