There's a quiet assumption baked into almost every Laravel AI integration tutorial: 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 is the framework's answer to lightweight API token authentication. 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:
composer show laravel/sanctum
If you're on an older install that upgraded to Laravel 11, you may need to install it:
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:
// 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:
'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:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
That trait 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.
// 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']
);
return response()->json([
'token' => $token->plainTextToken,
]);
}
public function revoke(Request $request): \Illuminate\Http\JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Token revoked.']);
}
}
The device_name field is part of Sanctum's design, it lets users see which device or client issued each token. The ValidationException approach returns a clean 422 with field-level errors rather than a 401, which is what most frontend clients expect.
Route registration:
// 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.
// 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
$token = $user->createToken('ai-assistant', ['ai:query', 'api:read']);
Check abilities in your controllers using tokenCan():
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 apply the abilities middleware directly on route groups:
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:
// 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');
});
});
The auth:sanctum guard resolves the authenticated user from the Bearer token, making $request->user() available throughout the request lifecycle.
Token Revocation
You need at minimum three revocation endpoints:
// Revoke current token (logout this device)
Route::delete('/auth/token', [TokenController::class, 'revoke'])
->middleware('auth:sanctum');
// Revoke a specific token by ID
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:
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 ensures only that user's tokens are in play 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_id, Laravel's migration doesn't include it by default in all versions.
// 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. Set an expiry in config/sanctum.php:
'expiration' => 60 * 24 * 30, // 30 days, in minutes
Pair this with a scheduled prune command:
// routes/console.php (Laravel 11)
Schedule::command('sanctum:prune-expired --hours=24')->daily();
Left unchecked, the personal_access_tokens table grows indefinitely and starts dragging down every authenticated request.
Rate Limiting with Redis
The auth:sanctum middleware doesn't include rate limiting. 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 budget in minutes.
Define rate limiters in bootstrap/app.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:
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. The default file driver won't share rate limit counts across multiple server instances.
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Efficiency Gain: Redis handles both rate limiting and Sanctum's token lookup cache on the same connection. At scale, Sanctum resolves the user on every request by hashing the incoming token and hitting the database. If you're seeing measurable auth overhead in production profiling, an application-level cache keyed to the token hash eliminates that DB hit - but profile first, optimize second.
Multi-Tenant Token Scoping
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 fix: store a team_id on the token itself using a custom token model.
Extend the default token model:
// 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',
];
}
Register it in AppServiceProvider:
use Laravel\Sanctum\Sanctum;
use App\Models\PersonalAccessToken;
public function boot(): void
{
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}
Add the column:
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->unsignedBigInteger('team_id')->nullable()->index();
});
Issue tokens with team context:
$token = $user->createToken($request->device_name, ['api:read', 'api:write']);
$token->accessToken->forceFill(['team_id' => $currentTeam->id])->save();
Authorize against both user and team on every request:
$tokenTeamId = $request->user()->currentAccessToken()->team_id;
if ($tokenTeamId !== $resource->team_id) {
abort(403, 'Token not authorized for this team.');
}
Listing Tokens for the Authenticated User
Give users visibility into their active tokens:
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]);
}
The raw token value is never returned here, that's only available at issuance via plainTextToken. After that, only the hash is stored.
Testing Sanctum Authentication
Sanctum ships with a clean testing helper:
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']);
$this->getJson('/api/documents')->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
$this->postJson('/api/documents', [
'title' => 'Test',
'content' => 'Test content',
])->assertStatus(403);
}
public function test_unauthenticated_request_is_rejected(): void
{
$this->getJson('/api/documents')->assertStatus(401);
}
}
Sanctum::actingAs() bypasses the actual token lookup and tells the 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 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:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
});
$exceptions->render(function (AuthorizationException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Forbidden.'], 403);
}
});
})
A 302 redirect from an API client that expected a 401 is a confusing failure mode that causes hours of frontend debugging.
SPA Authentication (Cookie-Based)
For a Vue or React SPA on the same top-level domain, Sanctum's stateful middleware handles auth through the session cookie — no tokens needed.
The flow: the SPA GETs /sanctum/csrf-cookie to initialize the CSRF cookie, POSTs credentials to your login endpoint, then includes the X-XSRF-TOKEN header on subsequent requests.
Configure your stateful domains in config/sanctum.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()
))),
Set SANCTUM_STATEFUL_DOMAINS explicitly in production. Wildcard domains are not supported.
Edge Case Alert: If your SPA and API are on different subdomains (
app.example.comandapi.example.com), the session cookie won't work across domains due to browser SameSite restrictions. Use token-based auth even for your SPA in this case. The cookie approach only works when both are served from the same domain.
Production Deployment Checklist
Database
-
personal_access_tokensmigration applied - Index on
tokenable_typeandtokenable_idexists - Token expiry configured and
sanctum:prune-expiredscheduled
Configuration
-
APP_KEYis set and rotated from the default -
SESSION_DRIVERisredisordatabasein multi-server setups -
CACHE_DRIVERisredisfor rate limiting across instances -
SANCTUM_STATEFUL_DOMAINSexplicitly set if using SPA auth
Rate Limiting
- Named rate limiters defined for all API route groups
- Stricter limiters applied to AI query endpoints
- Rate limit responses return
Retry-Afterheaders
Security
-
Authorizationheader stripped at the CDN for non-API routes - HTTPS enforced — tokens 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
- Log token revocation events for audit trails
What Sanctum Doesn't Cover
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.
For the overwhelming majority of Laravel APIs — including every AI integration pattern — Sanctum's personal access tokens, SPA sessions, ability scoping, and revocation model cover the full authentication lifecycle. Don't reach for Passport's complexity if Sanctum's model fits your use case. The operational overhead isn't trivial.
Official Documentation
Originally published at origin-main.com — Laravel + AI integration guides for production developers.