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.
- 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.
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.
{id}|{secret} format lets Sanctum fetch the token record by primary key first, then verify the hash — avoiding a full-table scan on the token column. If you ever see auth slow down under load, check whether you stripped the ID prefix before storing or forwarding the token. We had a mobile team that was splitting the token on the client side and only sending the secret portion. Every auth request did a full-table scan on a 4-million-row table. Login latency went from 12ms to 800ms. The fix was one line: stop stripping the ID.Complete Token Authentication Setup — Installation to Revocation
Let's build a real authentication flow: registration, login, token issuance with scoped abilities, protected routes, and clean logout with token revocation. This is the pattern you'd use for a mobile app or a CLI tool consuming your Laravel backend.
After composer require laravel/sanctum and php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider", run php artisan migrate to create the personal_access_tokens table. Add the HasApiTokens trait to your User model — that single trait is what gives Eloquent models the createToken(), , and tokens()currentAccessToken() methods.
The createToken() method accepts three arguments: a token name (for your own bookkeeping — show users which device has which token), an array of abilities, and an expiration via Illuminate\Support\Carbon. Abilities are the fine-grained permission strings Sanctum calls 'scopes' but internally stores as a JSON array. $token->can('read:invoices') is how you check them inside controllers — not Gate or Policy, just the token model itself.
Always revoke tokens on logout explicitly. If you just call Auth::logout() it only destroys the session — the personal_access_tokens row lives forever until manually deleted. That's a security hole in most tutorial code you'll find online.
One pattern I enforce on every team: per-device token naming. When a user logs in from 'iPhone 15 Pro', the token is named 'iPhone 15 Pro'. When they log in from 'Chrome on MacBook', that's a separate token with a different name. This means the user can see every active session in their account settings and revoke individual devices. I inherited a codebase once where every login created a token named 'api-token' — the user had 47 active tokens with no way to tell which device owned which one. We couldn't even tell if an attacker had issued one.
->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.SPA Cookie Authentication — The Complete Pattern
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.
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..myapp.com, the session cookie set by app.myapp.com is not sent to api.myapp.com.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.
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.
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 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.
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.Stripping the Token ID Prefix Caused 800ms Logins on a 4-Million-Row Table
personal_access_tokens table.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.{id}|{secret}). Validate on registration that the client never truncates it.- 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.
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).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./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.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.Key takeaways
sanctum:prune-expired daily to prevent token table bloat from degrading query performance.Sanctum::actingAs()actingAs() uses the web guard and gives false confidence.EnsureFrontendRequestsAreStateful is first in the api middleware group and CORS supports credentials.Common mistakes to avoid
5 patternsUsing `actingAs($user)` in API tests without specifying guard
Sanctum::actingAs($user, $abilities) or $this->actingAs($user, 'sanctum') in feature tests for API routes.Forgetting to call `/sanctum/csrf-cookie` before SPA login
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
{id}|{secret}. Validate on client that the token includes a pipe character and ID.Not scheduling `sanctum:prune-expired` and letting token table bloat
$schedule->command('sanctum:prune-expired --hours=24')->daily(); to app/Console/Kernel.php.Setting SESSION_DOMAIN without a leading dot when using subdomains
app.example.com can't authenticate API at api.example.com, gets 401.SESSION_DOMAIN=.example.com (note leading dot) to share session cookie across subdomains.Interview Questions on This Topic
Explain the difference between Sanctum's token-based auth and SPA session auth. When would you use each?
Frequently Asked Questions
That's Laravel. Mark it forged?
9 min read · try the examples if you haven't