DEV Community

Cover image for Laravel Sanctum API Authentication: The Complete Production Guide
Dewald Hugo
Dewald Hugo

Posted on • Originally published at origin-main.com

Laravel Sanctum API Authentication: The Complete Production Guide

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
})
Enter fullscreen mode Exit fullscreen mode

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',
    ],
],
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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']);
    });
Enter fullscreen mode Exit fullscreen mode

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');
    });

});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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.']);
}
Enter fullscreen mode Exit fullscreen mode

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_id column will cause measurable latency spikes. Add an index to personal_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']);
});
Enter fullscreen mode Exit fullscreen mode

Token Expiry

By default, Sanctum tokens don't expire. Set an expiry in config/sanctum.php:

'expiration' => 60 * 24 * 30, // 30 days, in minutes
Enter fullscreen mode Exit fullscreen mode

Pair this with a scheduled prune command:

// routes/console.php (Laravel 11)
Schedule::command('sanctum:prune-expired --hours=24')->daily();
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

Register it in AppServiceProvider:

use Laravel\Sanctum\Sanctum;
use App\Models\PersonalAccessToken;

public function boot(): void
{
    Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}
Enter fullscreen mode Exit fullscreen mode

Add the column:

Schema::table('personal_access_tokens', function (Blueprint $table) {
    $table->unsignedBigInteger('team_id')->nullable()->index();
});
Enter fullscreen mode Exit fullscreen mode

Issue tokens with team context:

$token = $user->createToken($request->device_name, ['api:read', 'api:write']);
$token->accessToken->forceFill(['team_id' => $currentTeam->id])->save();
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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:read silently passing a api:write endpoint 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);
        }
    });
})
Enter fullscreen mode Exit fullscreen mode

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()
))),
Enter fullscreen mode Exit fullscreen mode

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.com and api.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_tokens migration applied
  • Index on tokenable_type and tokenable_id exists
  • Token expiry configured and sanctum:prune-expired scheduled

Configuration

  • APP_KEY is set and rotated from the default
  • SESSION_DRIVER is redis or database in multi-server setups
  • CACHE_DRIVER is redis for rate limiting across instances
  • SANCTUM_STATEFUL_DOMAINS explicitly 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-After headers

Security

  • Authorization header 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_tokens table 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.

Top comments (0)