There’s a quiet assumption baked into almost every Laravel AI integration tutorial, including the ones on this site: authentication exists. Routes are protected. Tokens are issued. The API is locked down.
That assumption breaks the moment you sit down to build something real.
Laravel Sanctum API authentication is the framework’s answer to lightweight token-based access control for your API clients. It ships with Laravel, it integrates cleanly with Eloquent, and it handles the two most common authentication patterns — SPA cookie-based sessions and mobile/external API token issuance — without pulling in a full OAuth server. This guide covers both patterns, but it leans hard into the personal access token model, because that’s what you need when you’re building an API that your own frontend, mobile app, or third-party client will consume.
By the end, you’ll have a production-ready authentication layer: token issuance with ability scoping, protected routes, revocation endpoints, rate limiting via Redis, and a multi-tenant token pattern that holds up under real load. We’re also covering the Laravel 11/12 bootstrap/app.php configuration style throughout — no legacy Kernel.php references.
Let’s get into it.
What Sanctum Actually Does
Before writing a single line of code, you need to understand where Sanctum fits in the ecosystem. Sanctum is not OAuth. It doesn’t issue refresh tokens. It doesn’t support third-party authorization flows. If you need those things, reach for Laravel Passport.
What Sanctum does exceptionally well is issue hashed personal access tokens tied to your users table via a polymorphic personal_access_tokens table. Each token can carry a set of abilities — scoped permissions that your application checks at runtime. The token itself is a random string; only the SHA-256 hash lives in the database. That’s a sensible default.
For SPAs on the same domain, Sanctum piggybacks on Laravel’s existing session authentication via a cookie. This is the EnsureFrontendRequestsAreStateful middleware doing its job. We’ll touch on this briefly, but the majority of this guide focuses on token-based auth for API clients.
Installation and Setup (Laravel 11 / 12)
Sanctum ships with Laravel 11 and 12. If you’re starting a fresh project, it’s already in your composer.json. Confirm it’s there:
bash
composer show laravel/sanctum
If you’re on an older install that upgraded to Laravel 11, you may need to install it:
bash
composer require laravel/sanctum php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider" php artisan migrate
That publish step drops a config/sanctum.php file and a migration for the personal_access_tokens table. Run the migration before anything else.
bootstrap/app.php — The Laravel 11/12 Way
In Laravel 11, middleware registration moved out of Kernel.php and into bootstrap/app.php. This is where you register the Sanctum stateful middleware for SPA authentication:
php
// bootstrap/app.php
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})
Calling $middleware->statefulApi() is the clean Laravel 11 way to register Sanctum’s SPA middleware. Avoid manually listing the middleware class inline — statefulApi() handles the correct ordering.
For token-only APIs (no SPA), you can skip this entirely. Your token-protected routes will use the auth:sanctum guard, which handles everything through the Authorization: Bearer header.
Configuring the Guard
In config/auth.php, make sure your api guard points to Sanctum:
php
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'sanctum',
'provider' => 'users',
],
],
This means auth:api and auth:sanctum both resolve to the same thing. Pick one and be consistent across your routes.
Preparing the User Model
Add the HasApiTokens trait to your User model:
php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
That trait is what gives Eloquent’s User model the createToken(), tokens(), and currentAccessToken() methods. Everything downstream depends on this being in place.
Issuing Personal Access Tokens
Token issuance happens through a dedicated endpoint. Keep it clean: one controller, one responsibility.
php
// app/Http/Controllers/Auth/TokenController.php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use App\Models\User;
class TokenController extends Controller
{
public function issue(Request $request): \Illuminate\Http\JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
'device_name' => ['required', 'string', 'max:255'],
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken(
$request->device_name,
['api:read', 'api:write'] // token abilities
);
return response()->json([
'token' => $token->plainTextToken,
]);
}
public function revoke(Request $request): \Illuminate\Http\JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Token revoked.']);
}
}
A few things worth calling out here. The device_name field is part of Sanctum’s design — it lets users see which device or client issued each token. That’s table stakes for any application where users manage their own tokens. The ValidationException approach returns a clean 422 with field-level errors rather than a 401, which is what most frontend clients expect.
Route registration:
php
// routes/api.php
use App\Http\Controllers\Auth\TokenController;
Route::post('/auth/token', [TokenController::class, 'issue']);
Route::delete('/auth/token', [TokenController::class, 'revoke'])->middleware('auth:sanctum');
[Architect’s Note] Never issue tokens from inside a route closure. The moment you need to add logging, rate limiting, or ability scoping, you’re refactoring. Always use a dedicated controller from day one — the Service Container will thank you when you need to inject dependencies later.
Token Abilities: Scoped Permissions
Token abilities are Sanctum’s lightweight alternative to OAuth scopes. You define them as strings at issuance time, then check them at the route or controller level.
The createToken() method accepts an array of abilities as its second argument:
php
// Read-only token
$token = $user->createToken('mobile-client', ['api:read']);
// Full-access token
$token = $user->createToken('admin-panel', ['api:read', 'api:write', 'api:delete']);
// AI feature token (for your Claude/OpenAI integration endpoints)
$token = $user->createToken('ai-assistant', ['ai:query', 'api:read']);
Check abilities in your controllers using tokenCan():
php
public function store(Request $request): JsonResponse
{
if (! $request->user()->tokenCan('api:write')) {
return response()->json(['error' => 'Insufficient token abilities.'], 403);
}
// ... proceed with write operation
}
Or use the abilities middleware directly on routes — cleaner for route groups:
php
Route::middleware(['auth:sanctum', 'abilities:api:read,api:write'])
->group(function () {
Route::post('/documents', [DocumentController::class, 'store']);
});
Route::middleware(['auth:sanctum', 'ability:api:read'])
->group(function () {
Route::get('/documents', [DocumentController::class, 'index']);
});
Note the difference: abilities (plural) requires the token to have all listed abilities. ability (singular) requires at least one. This distinction catches people off guard in production.
Protecting Routes
Standard protected route group for a JSON API:
php
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('documents', DocumentController::class);
Route::prefix('ai')->group(function () {
Route::post('/query', [AiQueryController::class, 'query'])
->middleware('ability:ai:query');
Route::get('/history', [AiQueryController::class, 'history'])
->middleware('ability:api:read');
});
});
If you’re building AI integration endpoints on top of this — which is exactly the context of your Laravel Claude API integration guide or your Laravel OpenAI guide — those routes live inside this middleware group. The auth:sanctum guard resolves the authenticated user from the Bearer token, making $request->user() available throughout the request lifecycle.
Token Revocation
Token revocation is where a lot of tutorials drop the ball. They show you how to delete currentAccessToken() and call it done. Production needs more.
You need at minimum three revocation endpoints:
php
// Revoke current token (logout this device)
Route::delete('/auth/token', [TokenController::class, 'revoke'])
->middleware('auth:sanctum');
// Revoke a specific token by ID (user managing their own tokens)
Route::delete('/auth/tokens/{tokenId}', [TokenController::class, 'revokeById'])
->middleware('auth:sanctum');
// Revoke all tokens (logout everywhere)
Route::delete('/auth/tokens', [TokenController::class, 'revokeAll'])
->middleware('auth:sanctum');
The controller methods:
php
public function revokeById(Request $request, int $tokenId): JsonResponse
{
$deleted = $request->user()
->tokens()
->where('id', $tokenId)
->delete();
if (! $deleted) {
return response()->json(['error' => 'Token not found.'], 404);
}
return response()->json(['message' => 'Token revoked.']);
}
public function revokeAll(Request $request): JsonResponse
{
$request->user()->tokens()->delete();
return response()->json(['message' => 'All tokens revoked.']);
}
The ->where('id', $tokenId) scoping on the tokens relationship is critical. Without it, a user could delete another user’s token by guessing IDs — an IDOR vulnerability. The relationship scope ($request->user()->tokens()) ensures only that user’s tokens are in scope before the delete fires.
[Production Pitfall] Token revocation is only as fast as your database query. Under heavy concurrent traffic, a “revoke all” operation that hits an unindexed
tokenable_idcolumn will cause measurable latency spikes. Add an index topersonal_access_tokens.tokenable_idif you haven’t already — Laravel’s migration doesn’t include it by default in all versions.
php
// In a new migration
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->index(['tokenable_type', 'tokenable_id']);
});
Token Expiry
By default, Sanctum tokens don’t expire. That’s fine for development. It’s wrong for production. Set an expiry in config/sanctum.php:
php
'expiration' => 60 * 24 * 30, // 30 days, in minutes
Or set it per-environment in .env by pulling the value through config — you might want shorter-lived tokens in staging. Pair this with a scheduled command to prune expired tokens:
php
// routes/console.php (Laravel 11)
Schedule::command('sanctum:prune-expired --hours=24')->daily();
This keeps your personal_access_tokens table from becoming a graveyard of stale records. Left unchecked, that table grows indefinitely and starts dragging down every authenticated request.
Rate Limiting with Redis
The auth:sanctum middleware doesn’t include rate limiting out of the box. You’re responsible for adding it. This is not optional for any API that calls an external AI provider — without it, a single misbehaving client can burn through your OpenAI or Anthropic budget in minutes.
Define rate limiters in bootstrap/app.php (Laravel 11) or AppServiceProvider:
php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(60)->by($request->user()->id)
: Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('ai', function (Request $request) {
return $request->user()
? Limit::perMinute(10)->by($request->user()->id)
: Limit::perMinute(2)->by($request->ip());
});
Apply them to route groups:
php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
// standard API routes
});
Route::middleware(['auth:sanctum', 'throttle:ai'])->group(function () {
// AI query routes
});
Switch your cache driver to Redis for this to actually work under load. The default array or file driver will fail in any multi-server setup because rate limit counts won’t be shared across instances.
env
CACHE_DRIVER=redis REDIS_HOST=127.0.0.1 REDIS_PORT=6379
The Laravel AI Middleware: Token Tracking & Rate Limiting guide goes much deeper on per-user token budget enforcement and cost tracking at the middleware layer — that’s the natural next step once this Sanctum foundation is in place. The two patterns stack cleanly.
[Efficiency Gain] Use Redis for rate limiting and cache the authenticated user from Sanctum’s token lookup on the same Redis connection. Sanctum resolves the user on every request by hashing the incoming token and querying the database. At scale, that’s one DB hit per request. You can wrap this in an application-level cache keyed to the token hash — but only do this if you’re seeing measurable N+1 auth overhead in production profiling. Premature optimization here adds complexity without measurable gain for most apps.
Multi-Tenant Token Scoping
If you’re building a multi-tenant application, you need tokens scoped to a specific tenant, not just a user. A user who belongs to multiple organizations shouldn’t be able to use a token issued for Organization A to access Organization B’s data.
The cleanest approach: store a team_id (or organization_id) on the token itself using Sanctum’s custom token model.
First, extend the default token model:
php
// app/Models/PersonalAccessToken.php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumToken;
class PersonalAccessToken extends SanctumToken
{
protected $fillable = [
'name',
'token',
'abilities',
'expires_at',
'team_id', // custom column
];
}
Register the custom model in AppServiceProvider:
php
use Laravel\Sanctum\Sanctum;
use App\Models\PersonalAccessToken;
public function boot(): void
{
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}
Add the column to the tokens table:
php
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->unsignedBigInteger('team_id')->nullable()->index();
});
Issue tokens with the team context stored:
php
$token = $user->createToken(
$request->device_name,
['api:read', 'api:write']
);
// Store the team context on the token record
$token->accessToken->forceFill(['team_id' => $currentTeam->id])->save();
In your middleware or controllers, authorize against both the authenticated user and the token’s team:
php
$tokenTeamId = $request->user()->currentAccessToken()->team_id;
if ($tokenTeamId !== $resource->team_id) {
abort(403, 'Token not authorized for this team.');
}
This pattern feeds directly into the multi-tenant architecture patterns covered in the Production-Grade AI Architecture in Laravel guide, where tenant isolation at the service and query layer is discussed in depth. Authentication is the first gate; the authorization patterns at the service layer are the second.
Listing Tokens for the Authenticated User
Give users visibility into their active tokens. This is a table stakes feature for any application where API tokens are user-managed:
php
public function index(Request $request): JsonResponse
{
$tokens = $request->user()->tokens()
->select(['id', 'name', 'abilities', 'last_used_at', 'expires_at', 'created_at'])
->latest()
->get()
->map(function ($token) {
return [
'id' => $token->id,
'name' => $token->name,
'abilities' => $token->abilities,
'last_used_at' => $token->last_used_at?->toDateTimeString(),
'expires_at' => $token->expires_at?->toDateTimeString(),
'created_at' => $token->created_at->toDateTimeString(),
];
});
return response()->json(['tokens' => $tokens]);
}
Notice we’re not returning the raw token value — that’s only available at issuance time via plainTextToken. After that, only the hash is stored. There’s nothing to expose even if this endpoint is hit by an attacker.
Testing Sanctum Authentication
Sanctum ships with a dedicated testing helper that makes auth testing clean:
php
use Laravel\Sanctum\Sanctum;
class DocumentApiTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_list_documents(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['api:read']);
$response = $this->getJson('/api/documents');
$response->assertStatus(200);
}
public function test_token_without_write_ability_cannot_create_document(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['api:read']); // no api:write
$response = $this->postJson('/api/documents', [
'title' => 'Test',
'content' => 'Test content',
]);
$response->assertStatus(403);
}
public function test_unauthenticated_request_is_rejected(): void
{
$response = $this->getJson('/api/documents');
$response->assertStatus(401);
}
}
Sanctum::actingAs() accepts a user and an array of abilities. It bypasses the actual token lookup and tells Sanctum’s guard to treat the request as authenticated with those abilities. Your tests stay fast; no real tokens are issued.
[Word to the Wise] Test the ability checks explicitly. Developers regularly ship applications where the happy path tests pass but the authorization boundaries are never verified. A token with
api:readsilently passing aapi:writeendpoint in production is a data integrity problem, not just a security one. Write the negative cases — they catch the middleware misconfiguration you didn’t know you’d made.
Returning Consistent Auth Error Responses
By default, an unauthenticated request to a Sanctum-protected route returns a redirect to the login page. That’s wrong for a JSON API. Fix it in bootstrap/app.php:
php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
});
})
And for authorization failures:
php
$exceptions->render(function (AuthorizationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Forbidden.'], 403);
}
});
This is a small change that prevents hours of frontend debugging. A 302 redirect from an API client that expected a 401 is a confusing failure mode.
SPA Authentication (Cookie-Based)
For completeness: if your frontend is a Vue or React SPA on the same top-level domain, Sanctum’s stateful middleware handles authentication through the session cookie — no tokens needed.
The SPA flow:
- Frontend makes a GET request to
/sanctum/csrf-cookieto initialize the CSRF cookie. - Frontend POSTs credentials to your login endpoint.
- Laravel issues a session cookie.
- Subsequent requests include the CSRF token in the
X-XSRF-TOKENheader. - Sanctum’s stateful middleware validates against the session.
Configure your stateful domains in config/sanctum.php:
php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
In production, set SANCTUM_STATEFUL_DOMAINS explicitly in your .env. Wildcard domains are not supported and should never be used.
[Edge Case Alert] If your SPA and API are on different subdomains (e.g.,
app.example.comandapi.example.com), the session cookie won’t work across domains due to browser SameSite restrictions. In this case, use token-based auth even for your SPA. The cookie approach only works cleanly when both are on the same domain or are served from the same Laravel app.
Production Deployment Checklist
Before you ship this to production, run through the following:
Database
personal_access_tokensmigration is applied- Index on
tokenable_typeandtokenable_idexists - Token expiry is set and
sanctum:prune-expiredis scheduled
Configuration
APP_KEYis set and rotated from the defaultSESSION_DRIVERisredisordatabase(notfilein multi-server setups)CACHE_DRIVERisredisfor rate limiting to work across instancesSANCTUM_STATEFUL_DOMAINSis explicitly set if using SPA auth
Rate Limiting
- Named rate limiters are defined for all API route groups
- Separate, stricter limiters are applied to AI query endpoints
- Rate limit responses return
Retry-Afterheaders (Laravel does this automatically)
Security Headers
Authorizationheader is stripped at the CDN/load balancer for non-API routes- HTTPS is enforced; tokens in transit over HTTP is a non-starter
- Tokens are never logged — audit your logging configuration
Monitoring
- Track
personal_access_tokenstable row count as a metric - Alert on unusual token issuance spikes (potential credential stuffing)
- Log token revocation events for audit trails
The full deployment hardening context — server configuration, queue workers, environment parity — is covered in the Production-Grade AI Architecture in Laravel guide. Authentication is your first layer of defense; it doesn’t substitute for the infrastructure hardening that goes around it.
What Sanctum Doesn’t Cover
You’ll eventually hit the edges of what Sanctum handles. To be direct about it:
Sanctum doesn’t do: OAuth2 authorization flows, refresh tokens, third-party client authorization, dynamic scope negotiation, or JWT issuance. If any of those are requirements, use Passport.
Sanctum does do: Everything described in this guide. For the overwhelming majority of Laravel APIs — including every AI integration pattern on this site — that’s enough. Personal access tokens, SPA sessions, ability scoping, and a clean revocation model cover the full authentication lifecycle for a self-contained product.
Don’t reach for Passport’s complexity if Sanctum’s model fits your use case. The operational overhead of running Passport’s OAuth server isn’t trivial, and the vast majority of Laravel API projects don’t need it.
Official Documentation
The Laravel Sanctum documentation is well-maintained and worth bookmarking. The Laravel HTTP Tests documentation covers the full testing helper API, including Sanctum::actingAs() and assertion methods used throughout this guide.
Frequently Asked Questions
What is Laravel Sanctum used for?
Laravel Sanctum is a lightweight authentication package for Laravel APIs. It handles two patterns: personal access tokens for mobile apps and external API clients, and session-based cookie authentication for SPAs on the same domain. It ships with Laravel 11 and 12 and requires no OAuth server to operate.
What is the difference between Laravel Sanctum and Laravel Passport?
Sanctum issues simple personal access tokens and supports SPA cookie authentication. Passport implements a full OAuth2 server with refresh tokens, authorization code flows, and third-party client support. Use Sanctum unless your application explicitly requires OAuth2. Most Laravel APIs, including AI integration projects, don’t need Passport’s complexity.
How do you revoke a Laravel Sanctum token?
Call $request->user()->currentAccessToken()->delete() to revoke the token attached to the current request. To revoke a specific token by ID, scope the query to the authenticated user first: $request->user()->tokens()->where('id', $tokenId)->delete(). Always scope to the authenticated user to prevent IDOR vulnerabilities.
Do Laravel Sanctum tokens expire?
Not by default. Set an expiry by configuring 'expiration' in config/sanctum.php — the value is in minutes, so 60 * 24 * 30 equals 30 days. Schedule the sanctum:prune-expired Artisan command to run daily to clean up expired records and prevent the personal_access_tokens table from growing unbounded.
How do Sanctum token abilities work?
Token abilities are scoped permission strings assigned at issuance time via createToken('name', ['api:read', 'api:write']). Check them at runtime with $request->user()->tokenCan('api:write'), or apply the abilities middleware to route groups. The abilities middleware (plural) requires all listed abilities; the ability middleware (singular) requires at least one.
Does Laravel Sanctum work in a multi-tenant application?
Yes, with a custom token model. Extend Laravel\Sanctum\PersonalAccessToken, add a team_id column to the personal_access_tokens table, and register the custom model via Sanctum::usePersonalAccessTokenModel() in your AppServiceProvider. Store the tenant context on the token at issuance time, then verify it on every request before authorizing resource access.
Senior Laravel Developer and AI Architect with 10+ years in the trenches. Dewald writes about building resilient, cost-aware AI integrations and modernizing the Laravel developer workflow for the 2026 ecosystem.

