Mid-level 9 min · March 06, 2026

Laravel Sanctum — Token ID Omission Causes 800ms Logins

Stripping token ID prefix forces full-table scan on personal_access_tokens, causing login to degrade from 12ms to 800ms.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Laravel Sanctum provides two auth mechanisms: token-based for API clients and cookie-based for first-party SPAs.
  • Token auth stores only SHA-256 hash; plain token returned once. Lose it and you need a fresh one.
  • SPA auth delegates to the 'web' guard after matching stateful domain — no token stored in JavaScript.
  • Every authenticated token request does one indexed DB query (~1ms on a well-maintained table).
  • Production failure #1: not scheduling sanctum:prune-expired — token table bloat degrades queries from 5ms to 4 seconds.
  • Biggest mistake: confusing token auth with SPA session auth — same guard, different resolution paths and config.
Plain-English First

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.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?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.
Production Insight
The pipe delimiter in the raw token (id|secret) is critical for performance.
If you strip the ID part before storing or forwarding, Sanctum cannot use the primary key lookup and falls back to a full-table scan on the token hash column.
Always preserve the full token string including the ID prefix.
Key Takeaway
Always preserve the full token string including the ID prefix.
The id|secret format enables indexed lookup by primary key.
Stripping the prefix guarantees a full-table scan — do not do it.

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't even tell if an attacker had issued one.

ApiAuthController.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
<?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'], $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 authenticated this request.
        // Delete it immediately — this is the actual revocation.
        $request->user()->currentAccessToken()->delete();

        return response()->json(['message' => 'Token revoked successfully.']);
    }

    /**
     * Get the authenticated user's profile.
     * Protected by auth:sanctum middleware.
     */
    public function profile(Request $request): JsonResponse
    {
        return response()->json([
            'user' => $request->user()->only(['id', 'name', 'email']),
        ]);
    }
}
Output
// POST /api/register { "name": "Alice", "email": "alice@example.com", "password": "...", "password_confirmation": "...", "device_name": "iPhone 15 Pro" }
201 Created
{
"user": {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
},
"token": "1|s3cr3tT0k3nH4sh...",
"abilities": ["invoices:read", "profile:update"]
}
// POST /api/login { "email": "alice@example.com", "password": "...", "device_name": "iPhone 15 Pro" }
200 OK
{
"token": "2|n3wT0k3nH4sh..."
}
// GET /api/profile
// Header: Authorization: Bearer 2|n3wT0k3nH4sh...
200 OK
{
"user": {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
}
// POST /api/logout
// Header: Authorization: Bearer 2|n3wT0k3nH4sh...
200 OK
{
"message": "Token revoked successfully."
}
Pro Tip: Always delete old tokens for the same device before issuing a new one
If a user logs in 10 times from the same phone without the ->delete() step, they'll accumulate 10 active tokens. Each one is a valid credential for that device. The fix: before creating a new token, delete all existing tokens with the same name (device name). This also makes the 'device list' in the UI cleaner — one token per device.
Production Insight
Per-device token naming is not just cosmetic.
In a production app with 47 active tokens named 'api-token', you cannot differentiate sessions.
If an attacker compromises one device, you cannot revoke just that session. Enforce unique token names per device.
Key Takeaway
Name tokens per device (e.g., 'iPhone 15 Pro') so users can revoke individual sessions.
Always delete old tokens for the same device name before issuing a new one to prevent token accumulation.
The plain text token is only available once — store it securely client-side.

When your SPA runs on the same primary domain or a subdomain as your Laravel API, Sanctum can authenticate via the session cookie instead of sending a token. This means the browser automatically sends the session cookie on every request — no need to store a token in JavaScript (which is a major XSS risk).

The flow works like this: your SPA calls GET /sanctum/csrf-cookie which sets an XSRF-TOKEN cookie (accessible to JavaScript) and initiates a session. Then your SPA POSTs the credentials to the standard web guard login route (e.g., /login). Laravel authenticates the user and sets the laravel_session cookie (HttpOnly, not accessible to JS). Every subsequent request from the SPA includes both cookies. Sanctum's EnsureFrontendRequestsAreStateful middleware (first in the api middleware group) checks the Origin header against sanctum.stateful domains. If matched, it reads the session and authenticates the user via the web guard. No token header needed.

The critical pieces are the SANCTUM_STATEFUL_DOMAINS config and CORS with credentials. Both must align. If either is wrong, the SPA gets a 401. The session cookie is there, but Sanctum never reads it because the stateful domain check failed.

One warning: SPA auth requires that your API and SPA share a top-level domain. If your SPA is at app.example.com and API at api.example.com, set SESSION_DOMAIN=.example.com in your .env file. The leading dot makes the cookie available to all subdomains. Without it, the cookie set by the SPA is scoped to app.example.com and the API subdomain never receives it.

spa-auth.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// ── SPA (React/Axios) ───────────────────────────────────────────────────────
// File: services/api.js
import axios from 'axios';

// Configure Axios to send cookies with every request.
const apiClient = axios.create({
    baseURL: 'https://api.myapp.com',
    withCredentials: true,                    // Required — sends cookies from the browser
    xsrfCookieName: 'XSRF-TOKEN',             // Default, reads the cookie set by sanctum
    xsrfHeaderName: 'X-XSRF-TOKEN',           // Default, sends the CSRF token as a header
});

/**
 * Authenticate the SPA user via Sanctum's cookie-based session.
 * This is called once when the user logs in.
 */
export async function loginWithSanctum(email, password) {
    // Step 1: Fetch CSRF cookie from Sanctum.
    // This sets the XSRF-TOKEN cookie (accessible to JS) and starts a session.
    await apiClient.get('/sanctum/csrf-cookie');

    // Step 2: Post credentials to the Laravel login route (web guard).
    // Laravel validates, authenticates, and sets the laravel_session cookie.
    await apiClient.post('/login', { email, password });

    // Step 3: Now all subsequent requests are automatically authenticated.
    // The browser sends the session cookie; Sanctum reads it.
    const { data } = await apiClient.get('/api/user');
    return data;
}

/*
 * If you use fetch() instead of Axios:
 *   fetch('/sanctum/csrf-cookie', { credentials: 'include' })
 *   .then(() => fetch('/login', { method: 'POST', credentials: 'include', headers: {...} }))
 *   .then(() => fetch('/api/user', { credentials: 'include' }));
 */

// ── Laravel side: routes/api.php ──────────────────────────────────────────────
// Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
//     return $request->user();
// });

// ── Laravel side: config/cors.php ─────────────────────────────────────────────
// return [
//     'paths' => ['api/*', 'sanctum/csrf-cookie', 'login'],
//     'allowed_methods' => ['*'],
//     'allowed_origins' => ['https://app.myapp.com'],   // Your SPA's exact origin
//     'supports_credentials' => true,                     // Required for cookies
// ];

// ── Laravel side: .env ────────────────────────────────────────────────────────
// SANCTUM_STATEFUL_DOMAINS=app.myapp.com,localhost
// SESSION_DOMAIN=.myapp.com
// SESSION_DRIVER=cookie

// ── Laravel side: app/Http/Kernel.php ─────────────────────────────────────────
// 'api' => [
//     \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
//     'throttle:api',
//     \Illuminate\Routing\Middleware\SubstituteBindings::class,
// ],

/*
 * ── Flow Summary ──
 * 1. GET /sanctum/csrf-cookie  →  Set-Cookie: XSRF-TOKEN=..., laravel_session=...
 * 2. POST /login {email,password}  →  Laravel authenticates, session cookie remains.
 * 3. GET /api/user  →  Browser sends cookies, Sanctum reads session, returns user.
 */
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.
Production Insight
The most common subdomain bug: forgetting to set SESSION_DOMAIN with a leading dot.
Without .myapp.com, the session cookie set by app.myapp.com is not sent to api.myapp.com.
This causes all API requests to return 401 even though the user is logged in.
Key Takeaway
Set SESSION_DOMAIN=.yourdomain.com for shared subdomain session cookies.
Ensure EnsureFrontendRequestsAreStateful is first in the api middleware group.
The CSRF cookie route must be called before any state-changing request.

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.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?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 Insight
If your mobile app sends two simultaneous refresh requests, the first one consumes the refresh token and the second fails, logging the user out.
Implement request deduplication on the client: if a refresh is in-flight, queue subsequent refresh attempts until it completes.
This race condition is the #1 support ticket in apps with custom refresh flows.
Key Takeaway
Refresh token rotation invalidates the old token on each use — this detects theft but requires client-side request deduplication.
Store refresh tokens in a separate table with expiry.
Short-lived access tokens (1 hour) limit blast radius.

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.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
<?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
    {
        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.
Production Insight
On a high-traffic app, the personal_access_tokens table can grow to 14 million rows if tokens are never pruned.
This caused login to take 4 seconds.
Adding a composite index on (tokenable_type, tokenable_id) and daily pruning dropped it to 40ms.
Key Takeaway
Schedule sanctum:prune-expired daily — token bloat is silent until queries degrade.
Add composite index on tokenable_type and tokenable_id for multi-model auth.
Cache tokens with caution: revocation delay trade-off.

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 never exercise the actual auth middleware. Always use Sanctum::actingAs($user) or $this->actingAs($user, 'sanctum').

For ability testing, pass abilities as the second argument: Sanctum::actingAs($user, ['invoices:read']). This creates a token behind the scenes and attaches it to the test request. For full token lifecycle tests (create, consume, expire, revoke), create a token explicitly with $user->createToken(...), extract plainTextToken, and send it manually.

For SPA auth tests, simulate the full flow: call /sanctum/csrf-cookie, extract the XSRF-TOKEN from the response cookie, and send it as a header on subsequent requests. This ensures your CSRF handling is tested end-to-end.

SanctumTest.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?php

namespace App\IO\TheCodeForge\Tests\Feature;

use App\IO\TheCodeForge\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class ApiAuthTest extends TestCase
{
    use RefreshDatabase;

    /**
     * PATTERN 1: Sanctum::actingAs() — most common, use this.
     * Creates a token with the given abilities and authenticates the user.
     */
    public function test_authenticated_user_can_access_profile(): void
    {
        $user = User::factory()->create();

        Sanctum::actingAs(
            $user,
            ['profile:read'] // Token abilities — optional
        );

        $response = $this->getJson('/api/user');

        $response->assertOk()
                 ->assertJson(['id' => $user->id]);
    }

    /**
     * PATTERN 2: Real token for testing ability enforcement.
     * Useful when you need to verify that tokens without a specific ability get a 403.
     */
    public function test_token_without_ability_gets_403(): void
    {
        $user = User::factory()->create();

        $token = $user->createToken(
            name: 'test-token',
            abilities: ['profile:read']  // NOT 'invoices:read'
        );

        $response = $this->withHeaders([
            'Authorization' => 'Bearer ' . $token->plainTextToken,
        ])->getJson('/api/invoices');  // This route requires 'invoices:read'

        $response->assertForbidden();
    }

    /**
     * PATTERN 3: SPA cookie auth test — full flow with CSRF.
     * Tests that the CSRF cookie is set and that credentials authenticate the session.
     */
    public function test_spa_login_flow(): void
    {
        $user = User::factory()->create([
            'email'    => 'alice@example.com',
            'password' => bcrypt('secret123!'),
        ]);

        // Step 1: Get CSRF cookie
        $csrfResponse = $this->get('/sanctum/csrf-cookie');
        $csrfResponse->assertNoContent();

        // Extract the XSRF-TOKEN from the cookie
        $xsrfToken = $csrfResponse->getCookie('XSRF-TOKEN', false)->getValue();

        // Step 2: Login with credentials and CSRF token
        $loginResponse = $this->postJson('/login', [
            'email'    => 'alice@example.com',
            'password' => 'secret123!',
        ], [
            'X-XSRF-TOKEN' => $xsrfToken,
            'Referer'      => 'http://localhost',  // Must match stateful domain
        ]);

        $loginResponse->assertOk();

        // Step 3: Access protected API endpoint
        $profileResponse = $this->withHeader('Referer', 'http://localhost')
                                ->getJson('/api/user');
        $profileResponse->assertOk()
                        ->assertJson(['email' => 'alice@example.com']);
    }

    /**
     * Ensure that bare actingAs() is NOT used — it defaults to 'web' guard.
     * This test would fail if we accidentally used it.
     */
    public function test_bare_actingAs_does_not_work_for_api(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user);  // Default 'web' guard — this will NOT authenticate API requests

        $response = $this->getJson('/api/user');
        $response->assertUnauthorized();  // Expect 401, not 200
    }
}
Output
// Test results (running vendor/bin/phpunit):
✓ test_authenticated_user_can_access_profile
✓ test_token_without_ability_gets_403
✓ test_spa_login_flow
✓ test_bare_actingAs_does_not_work_for_api
// Note: The last test intentionally expects 401. If it passes (401), that means
// the auth guard is correctly rejecting the request. If it fails (200), the
// test suite would have given false confidence.
Why most teams miss this until production:
The actingAs() helper without a guard argument authenticates against the default guard defined in config/auth.php — usually 'web'. In your API tests, the request goes through the 'api' middleware group but the auth is resolved by the 'web' guard because no header is present. This can silently succeed. Sanctum's guard is never invoked. Always use Sanctum::actingAs() or specify the guard explicitly.
Production Insight
Using actingAs($user) in API tests defaults to the 'web' guard.
Tests pass but production uses 'sanctum' guard, so auth fails.
This false confidence can go unnoticed until deployment.
Key Takeaway
Always use Sanctum::actingAs($user) for API tests.
Test token abilities explicitly.
For SPA auth tests, simulate the full CSRF cookie flow.
● Production incidentPOST-MORTEMseverity: high

Stripping the Token ID Prefix Caused 800ms Logins on a 4-Million-Row Table

Symptom
Login and authenticated requests became progressively slower over weeks, from 12ms to 800ms, despite indexed personal_access_tokens table.
Assumption
The team assumed the token ID prefix (the part before the pipe) was unnecessary overhead and stored only the 40-character secret.
Root cause
Sanctum's findToken() splits the raw token on the pipe character. Without the ID prefix, it cannot do a primary key lookup and resorts to SELECT * FROM personal_access_tokens WHERE token = ? — a full-table scan on the SHA-256 hash column.
Fix
Store and transmit the full token string including the ID prefix ({id}|{secret}). Validate on registration that the client never truncates it.
Key lesson
  • The token ID prefix is not overhead — it is the performance key.
  • If you see auth queries degrading over time, check whether tokens are being truncated before storage.
  • Always log token length on issuance and validate on first authenticated request.
Production debug guideDiagnose the most common Sanctum auth failures in production with these step-by-step checks.4 entries
Symptom · 01
SPA returns 401 after login — session cookie is set but not accepted
Fix
1. Verify the request Origin matches a domain in sanctum.stateful config. 2. Check EnsureFrontendRequestsAreStateful is the first middleware in the 'api' group. 3. Confirm CORS config has supports_credentials => true and the frontend sends withCredentials: true. 4. Ensure SESSION_DOMAIN is set correctly (with leading dot for subdomains).
Symptom · 02
Token auth works but is slow under load (query time > 100ms)
Fix
1. Run EXPLAIN SELECT * FROM personal_access_tokens WHERE id = ? — ensure it uses primary key lookup. 2. Check if tokens include the ID prefix (look at a few raw tokens in the DB). 3. If no prefix, request the client to resend full tokens. 4. Measure personal_access_tokens row count; run sanctum:prune-expired if too large.
Symptom · 03
CSRF token mismatch error on SPA requests
Fix
1. Ensure frontend calls /sanctum/csrf-cookie before any state-changing request. 2. Verify the frontend reads the XSRF-TOKEN cookie and sends it as the X-XSRF-TOKEN header. 3. Check cookie domain and same-site attributes — SameSite=Lax is required for cross-site requests.
Symptom · 04
Token revocation doesn't take effect — cached token still works
Fix
1. Check if token caching is enabled (custom PersonalAccessToken model with cache). 2. If caching, ensure token deletion busts the cache (Cache::forget). 3. For security-critical apps, disable token caching entirely. 4. Verify the token's expires_at is set and in the past.
★ Sanctum Auth Debug Cheat SheetQuick commands and checks to resolve the most common Sanctum issues in production.
SPA auth returning 401
Immediate action
Check the request origin vs SANCTUM_STATEFUL_DOMAINS config.
Commands
php artisan config:show sanctum.stateful
curl -v -H 'Origin: https://your-spa.com' -b 'laravel_session=...' https://api.yourdomain.com/api/user
Fix now
Add the SPA domain to SANCTUM_STATEFUL_DOMAINS in .env and clear config cache.
Token auth slow (< 100ms but you want < 10ms)+
Immediate action
Check if token ID prefix is present.
Commands
SELECT id, LEFT(token, 20) AS token_prefix, LENGTH(token) FROM personal_access_tokens LIMIT 5;
Check query plan: EXPLAIN SELECT * FROM personal_access_tokens WHERE id = 1;
Fix now
If token length is 64 (no prefix), issue new tokens with full format. If table is huge, run pruning.
Token revocation not working+
Immediate action
Check if you are using custom cached PersonalAccessToken model.
Commands
grep -r 'usePersonalAccessTokenModel' app/Providers/*.php
Check cache keys: redis-cli KEYS '*sanctum*' or files in storage/framework/cache
Fix now
If caching is active, ensure the deleted event busts the cache. Or disable caching for tokens entirely.
CORS error on SPA requests+
Immediate action
Check CORS configuration in config/cors.php.
Commands
php artisan config:show cors
Check browser console for CORS error messages.
Fix now
Set 'supports_credentials' => true, and add correct 'allowed_origins'. Clear config cache.

Key takeaways

1
Sanctum provides two distinct auth mechanisms
token-based for external clients, cookie-based for first-party SPAs — never confuse the two.
2
The pipe delimiter in the raw token is a performance feature
use it to enable indexed primary key lookups.
3
Always schedule sanctum:prune-expired daily to prevent token table bloat from degrading query performance.
4
Use per-device token names and delete old tokens on re-login to prevent session accumulation and enable granular revocation.
5
Test API auth with Sanctum::actingAs()
bare actingAs() uses the web guard and gives false confidence.
6
For SPA auth, ensure EnsureFrontendRequestsAreStateful is first in the api middleware group and CORS supports credentials.
7
Sanctum does not support refresh tokens natively; implement your own with rotation and deduplicate concurrent refresh requests.
8
Token caching improves speed but delays revocation
skip it for security-critical applications.

Common mistakes to avoid

5 patterns
×

Using `actingAs($user)` in API tests without specifying guard

Symptom
Tests pass locally, but production API returns 401 because the 'web' guard was used in tests, not 'sanctum'.
Fix
Always use Sanctum::actingAs($user, $abilities) or $this->actingAs($user, 'sanctum') in feature tests for API routes.
×

Forgetting to call `/sanctum/csrf-cookie` before SPA login

Symptom
CSRF token mismatch error on SPA requests after login.
Fix
Ensure the SPA calls GET /sanctum/csrf-cookie before any state-changing POST request. The XSRF-TOKEN cookie must be sent back as X-XSRF-TOKEN header.
×

Stripping the token ID prefix (the part before the pipe) before storing or transmitting

Symptom
All authenticated requests become slow (full table scan) as token table grows.
Fix
Always preserve the full token string {id}|{secret}. Validate on client that the token includes a pipe character and ID.
×

Not scheduling `sanctum:prune-expired` and letting token table bloat

Symptom
Auth queries degrade from milliseconds to seconds over weeks, table grows to millions.
Fix
Add $schedule->command('sanctum:prune-expired --hours=24')->daily(); to app/Console/Kernel.php.
×

Setting SESSION_DOMAIN without a leading dot when using subdomains

Symptom
SPA at app.example.com can't authenticate API at api.example.com, gets 401.
Fix
Set SESSION_DOMAIN=.example.com (note leading dot) to share session cookie across subdomains.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between Sanctum's token-based auth and SPA sessio...
Q02SENIOR
How does Sanctum look up a token, and why does the pipe delimiter (id|se...
Q03SENIOR
A SPA on `app.example.com` is getting 401s from the API at `api.example....
Q04SENIOR
How do you implement a token refresh mechanism in Sanctum, and what race...
Q05SENIOR
Why does calling `Auth::logout()` not revoke Sanctum tokens?
Q01 of 05SENIOR

Explain the difference between Sanctum's token-based auth and SPA session auth. When would you use each?

ANSWER
Token-based auth is for mobile apps, CLI tools, or third-party API consumers. The client receives a plaintext token once and sends it as a Bearer header on every request. Sanctum stores only the SHA-256 hash of the token. SPA session auth is for first-party SPAs that run on the same domain or a subdomain as the Laravel API. It uses the standard 'web' guard and session cookies. No token is stored in JavaScript, eliminating XSS token theft risk. Choose token auth for external clients; SPA auth for your own frontend on the same domain.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use Sanctum for both API and web authentication simultaneously?
02
How do I expire a Sanctum token without using an `expires_at` value?
03
Does Sanctum support multiple guards for different user types?
04
What happens if I change the `SANCTUM_STATEFUL_DOMAINS` after tokens have been issued?
05
Is it safe to store Sanctum tokens in localStorage?
🔥

That's Laravel. Mark it forged?

9 min read · try the examples if you haven't

Previous
Laravel Testing with PHPUnit
14 / 15 · Laravel
Next
Laravel Broadcasting