Laravel Sanctum vs Passport: Which Auth Package Should You Use in 2026?

SPA authentication, API tokens, OAuth — Sanctum and Passport solve different problems. Here’s the definitive breakdown of when to use each, with real implementation patterns for both.

Authentication is where most Laravel decisions live the longest. Pick the wrong abstraction early and you’re either carrying unnecessary OAuth2 complexity for a simple SPA, or retrofitting OAuth flows onto a Sanctum setup that was never designed for them.

In 2026, with Laravel 11 and 12 having shifted the ecosystem towards simplicity, Sanctum is now the default API stack installed when you run php artisan install:api. But that doesn’t mean Passport is obsolete — it means the ecosystem has gotten better at knowing when each tool is appropriate.

This guide gives you the mental model, the comparison, and the complete implementation patterns for both — so you make the right call once and don’t revisit it.


The One-Line Distinction

Before any code: the clearest possible summary of the difference.

Laravel Passport is a complete OAuth2.0 implementation designed for powerful, API-driven apps that require sophisticated permissions, token lifecycles, and third-party interfaces. Laravel Sanctum is a simpler, featherweight solution for securing Single-Page Applications and first-party applications, avoiding the complexity of OAuth2.

The key phrase is first-party vs third-party.

  • Sanctum is for applications where you control both the API and the client. Your React SPA calling your Laravel API. Your mobile app calling your backend. Your team’s dashboard.
  • Passport is for applications where third-party clients need to access your users’ data. “Sign in with Your App.” Developer APIs. OAuth flows between services.

Everything else follows from that distinction.


How Each Package Works

Laravel Sanctum

Sanctum provides two authentication mechanisms, and understanding both is important because they work very differently.

1. Cookie-Based SPA Authentication

If you have a Single Page Application built with Vue, React, or Svelte that communicates with a Laravel backend on the same domain, Sanctum is unbeatable. It uses stateful cookie-based authentication. This means you do not need to store API tokens in local storage, which protects your users from Cross-Site Scripting (XSS) attacks.

The flow: your SPA hits /sanctum/csrf-cookie to initialise the CSRF token, then logs in via /login. Laravel sets a session cookie. Every subsequent request from the SPA carries that cookie. The API reads it as an authenticated session — no tokens in JavaScript, no local storage vulnerabilities.

2. Personal Access Tokens

Sanctum allows users to generate multiple API tokens for their account. Each token can be granted specific abilities (scopes), which govern what actions can be performed using the token.

These are plain opaque tokens stored in the personal_access_tokens database table. They’re perfect for “Generate API Key” functionality — the same pattern GitHub, Stripe, and most SaaS platforms use for developer integrations.

Laravel Passport

Laravel Passport implements the complex OAuth2 server standard, which is designed for providing specific authorization flows for web applications, desktop applications, mobile phones, and many more devices.

Passport supports the full suite of OAuth2 grant types:

  • Authorization Code Grant — the standard “Sign in with [App]” flow. User sees a consent screen, approves, gets redirected with an authorization code, which is exchanged for tokens.
  • Client Credentials Grant — machine-to-machine authentication. No user involved. Service A authenticates to Service B.
  • Implicit Grant — deprecated in OAuth 2.1, avoid in 2026.
  • Device Authorization Grant — for devices without browsers (smart TVs, CLIs).
  • Personal Access Tokens — Passport also supports these, but with more infrastructure overhead than Sanctum.
  • Refresh Tokens — long-lived sessions with rotating tokens.

The Decision Matrix

Are third-party apps accessing your users' data?
├── Yes → Use Passport (OAuth2 Authorization Code flow)
└── No
    ├── Is it machine-to-machine (no user)?
    │   ├── Yes → Use Passport (Client Credentials grant)
    │   └── No
    │       ├── Is it a first-party SPA on the same/subdomain?
    │       │   └── Use Sanctum (cookie-based session auth)
    │       ├── Is it a mobile app or external first-party client?
    │       │   └── Use Sanctum (personal access tokens)
    │       └── Simple API with user-generated keys?
    │           └── Use Sanctum (personal access tokens)
ScenarioRecommendation
Vue/React SPA + Laravel API (same domain)Sanctum — cookie auth
React Native / Flutter mobile appSanctum — personal access tokens
“Generate API Key” for usersSanctum — personal access tokens
“Sign in with [Your App]” (OAuth provider)Passport — authorization code grant
Third-party developers querying your APIPassport — authorization code grant
Service-to-service (no user)Passport — client credentials grant
Enterprise SSO / OpenID ConnectPassport — authorization code + PKCE
Simple SaaS, you own frontend and backendSanctum
Public API marketplace (like Stripe, Twilio)Passport

Implementing Laravel Sanctum

Installation

composer require laravel/sanctum

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

Add the HasApiTokens trait to your User model:

// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

Pattern 1: SPA Cookie Authentication

// config/sanctum.php — set your SPA domains
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
    '%s%s',
    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
    Sanctum::currentApplicationUrlWithPort()
))),
// routes/api.php — Sanctum SPA middleware
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $request) => $request->user());
    Route::apiResource('posts', PostController::class);
});
// Frontend — the SPA auth flow (React/Vue/any)

// Step 1: Initialise CSRF protection
await axios.get('/sanctum/csrf-cookie')

// Step 2: Log in — this sets a session cookie
await axios.post('/login', { email, password })

// Step 3: All subsequent requests use the cookie automatically
// No token management, no Authorization header, nothing else needed
const { data } = await axios.get('/api/user')
// AuthController.php — login endpoint
public function login(Request $request): JsonResponse
{
    $credentials = $request->validate([
        'email'    => ['required', 'email'],
        'password' => ['required'],
    ]);

    if (!Auth::attempt($credentials, $request->boolean('remember'))) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    $request->session()->regenerate();

    return response()->json(['user' => Auth::user()]);
}

public function logout(Request $request): JsonResponse
{
    Auth::guard('web')->logout();

    $request->session()->invalidate();
    $request->session()->regenerateToken();

    return response()->json(['message' => 'Logged out']);
}

CORS matters here. When your SPA lives on app.example.com and your API on api.example.com, configure config/cors.php with 'supports_credentials' => true and set the SANCTUM_STATEFUL_DOMAINS to include your SPA’s domain. Without this, cookie authentication won’t work cross-subdomain.

Pattern 2: Personal Access Tokens (Mobile / API Keys)

// Issuing a token — typically from a login or token-generation endpoint
public function createToken(Request $request): JsonResponse
{
    $request->validate([
        'email'       => ['required', 'email'],
        'password'    => ['required'],
        'device_name' => ['required', 'string'],
    ]);

    $user = User::where('email', $request->email)->first();

    if (!$user || !Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    // Create a token with specific abilities (scopes)
    $token = $user->createToken(
        name:      $request->device_name,
        abilities: ['posts:read', 'posts:write', 'profile:read'],
    )->plainTextToken;

    return response()->json(['token' => $token]);
}

// Token is returned once — the client must store it securely
// Usage: Authorization: Bearer {token}
// Protecting routes — same middleware regardless of cookie or token
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);

    // Check token abilities within the controller
    Route::post('/posts', function (Request $request) {
        if (!$request->user()->tokenCan('posts:write')) {
            abort(403, 'Insufficient token abilities');
        }
        // ...
    });
});
// Token management — listing and revoking
public function listTokens(Request $request): JsonResponse
{
    return response()->json(
        $request->user()->tokens()->select('id', 'name', 'last_used_at', 'created_at')->get()
    );
}

public function revokeToken(Request $request, int $tokenId): JsonResponse
{
    $request->user()->tokens()->where('id', $tokenId)->delete();
    return response()->json(['message' => 'Token revoked']);
}

public function revokeAllTokens(Request $request): JsonResponse
{
    $request->user()->tokens()->delete();
    return response()->json(['message' => 'All tokens revoked']);
}

Implementing Laravel Passport

Installation

composer require laravel/passport

php artisan migrate
php artisan passport:install
# This generates the encryption keys and creates the default OAuth clients
// app/Models/User.php
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}
// app/Providers/AuthServiceProvider.php
use Laravel\Passport\Passport;

public function boot(): void
{
    // Set token expiration
    Passport::tokensExpireIn(now()->addDays(15));
    Passport::refreshTokensExpireIn(now()->addDays(30));
    Passport::personalAccessTokensExpireIn(now()->addMonths(6));
}
// config/auth.php — use passport as the API guard driver
'guards' => [
    'api' => [
        'driver'   => 'passport',
        'provider' => 'users',
    ],
],

Pattern 1: Authorization Code Grant (“Sign in with Your App”)

This is the OAuth flow users see when they click “Authorize” on a third-party consent screen.

// routes/web.php — Passport registers these automatically
// GET  /oauth/authorize   → consent screen
// POST /oauth/authorize   → user approves
// GET  /oauth/token       → exchange code for tokens

// Customising the consent view
php artisan vendor:publish --tag=passport-views
// → resources/views/vendor/passport/authorize.blade.php
// Creating an OAuth client for a third-party application
// (done once, typically via the Passport admin UI or artisan)
php artisan passport:client --name="Third Party App" --redirect="https://thirdparty.com/callback"

// Or programmatically:
use Laravel\Passport\ClientRepository;

$clients = app(ClientRepository::class);
$client  = $clients->create(
    userId:   null,
    name:     'Third Party App',
    redirect: 'https://thirdparty.com/callback',
    personalAccess: false,
    password: false,
    confidential: true,
);
// The third-party app redirects users to:
// GET /oauth/authorize?response_type=code
//     &client_id={client_id}
//     &redirect_uri=https://thirdparty.com/callback
//     &scope=posts:read profile:read
//     &state={random_state}

// After user approves, your app redirects to:
// https://thirdparty.com/callback?code={auth_code}&state={state}

// Third-party exchanges code for token:
$response = Http::asForm()->post('https://yourapp.com/oauth/token', [
    'grant_type'    => 'authorization_code',
    'client_id'     => $clientId,
    'client_secret' => $clientSecret,
    'redirect_uri'  => 'https://thirdparty.com/callback',
    'code'          => $authorizationCode,
]);

// Response:
// { "access_token": "...", "refresh_token": "...", "expires_in": 1296000 }

Pattern 2: Client Credentials Grant (Machine-to-Machine)

For service-to-service authentication where no user is involved.

# Create a client credentials client
php artisan passport:client --client --name="Analytics Service"
// routes/api.php — protect routes with client credentials guard
Route::middleware('client')->group(function () {
    Route::get('/analytics/summary', [AnalyticsController::class, 'summary']);
    Route::post('/internal/sync', [SyncController::class, 'trigger']);
});
// Service A authenticating to Service B
$response = Http::asForm()->post('https://yourapp.com/oauth/token', [
    'grant_type'    => 'client_credentials',
    'client_id'     => config('services.analytics.client_id'),
    'client_secret' => config('services.analytics.client_secret'),
    'scope'         => 'analytics:read',
]);

$token = $response->json('access_token');

// Use it:
Http::withToken($token)->get('https://yourapp.com/api/analytics/summary');

Pattern 3: Token Scopes

Scopes are Passport’s mechanism for fine-grained permission control.

// app/Providers/AuthServiceProvider.php
use Laravel\Passport\Passport;

public function boot(): void
{
    Passport::tokensCan([
        'posts:read'    => 'Read posts and articles',
        'posts:write'   => 'Create and update posts',
        'posts:delete'  => 'Delete posts',
        'profile:read'  => 'Read user profile information',
        'profile:write' => 'Update user profile',
        'admin'         => 'Full administrative access',
    ]);

    // Default scopes issued when none are requested
    Passport::setDefaultScope([
        'posts:read',
        'profile:read',
    ]);
}
// Checking scopes in controllers
Route::middleware(['auth:api', 'scopes:posts:write'])->group(function () {
    Route::post('/posts', [PostController::class, 'store']);
});

// Or check individual scope:
Route::middleware('auth:api')->post('/posts', function (Request $request) {
    if ($request->user()->tokenCan('posts:write')) {
        // create post
    }
});

Pattern 4: Refresh Tokens

Refresh tokens let clients obtain new access tokens without requiring the user to re-authenticate.

// Exchange a refresh token for a new access token
$response = Http::asForm()->post('https://yourapp.com/oauth/token', [
    'grant_type'    => 'refresh_token',
    'refresh_token' => $currentRefreshToken,
    'client_id'     => $clientId,
    'client_secret' => $clientSecret,
    'scope'         => 'posts:read profile:read',
]);

// { "access_token": "new_token", "refresh_token": "new_refresh", "expires_in": ... }

Sanctum vs Passport: Full Comparison

FeatureSanctumPassport
ComplexitySimple, minimal configComplex, full OAuth2 server
Default in Laravel 11/12Yes (install:api)No
SPA cookie auth✅ First-class❌ Not supported
Personal access tokens✅ Simple, lightweight✅ Heavier, more overhead
OAuth2 authorization code❌ Not supported✅ Full implementation
Client credentials grant❌ Not supported✅ Full implementation
Refresh tokens❌ Not supported✅ Full support
Token scopes/abilities✅ Simple abilities✅ Full OAuth2 scopes
Third-party OAuth provider
Machine-to-machine auth
Installation time~5 minutes~20–30 minutes
Database tables required1 (personal_access_tokens)5+ (oauth_* tables)
XSS protection (cookie auth)✅ Tokens never in JS⚠️ Bearer tokens in storage
Best forSPAs, mobile, first-party APIsPublic APIs, OAuth providers, enterprise

The “Use Both” Scenario

It is possible but rarely recommended to use both. You might use Sanctum for your own frontend and Passport for external developer APIs.

This comes up in practice when you’re building a product with two distinct client types:

  1. Your own SPA/mobile app — uses Sanctum cookie auth or personal access tokens
  2. Third-party developers — need OAuth2 flows to access user data
// config/auth.php — two separate guards
'guards' => [
    'web' => [
        'driver'   => 'session',
        'provider' => 'users',
    ],
    'sanctum' => [
        'driver'   => 'sanctum',
        'provider' => 'users',
    ],
    'passport' => [
        'driver'   => 'passport',
        'provider' => 'users',
    ],
],
// routes/api.php — two route groups, two guards
// First-party SPA and mobile
Route::middleware('auth:sanctum')->prefix('v1')->group(function () {
    Route::get('/user', fn(Request $r) => $r->user());
    Route::apiResource('posts', PostController::class);
});

// Third-party OAuth clients
Route::middleware('auth:passport')->prefix('v1/public')->group(function () {
    Route::get('/user', fn(Request $r) => $r->user());
    Route::get('/posts', [PostController::class, 'index']);
});

This pattern is more maintenance overhead, but it cleanly separates the concerns and lets each client type use the right authentication mechanism.


Security Considerations in 2026

Sanctum SPA Auth — What Makes It Secure

  • Tokens never touch JavaScript — they live in HttpOnly cookies that JS cannot read
  • CSRF protection via the X-XSRF-TOKEN header (set automatically by Axios and Fetch with the right config)
  • Session expiration managed server-side
  • No token storage vulnerability possible
// Ensure your session cookies are secure in production
// config/session.php
'secure'    => env('SESSION_SECURE_COOKIE', true),  // HTTPS only
'same_site' => 'lax',                                // CSRF protection
'http_only' => true,                                 // JS cannot read

Sanctum Personal Access Tokens — Storage Warning

When using personal access tokens for mobile or API clients, the token is a bearer token sent in the Authorization header. The client must store it securely:

  • Mobile: Use the platform’s secure storage (Keychain on iOS, Keystore on Android)
  • Never store tokens in localStorage — vulnerable to XSS
  • Consider token expiration: set expiration in config/sanctum.php
// config/sanctum.php — token expiration (minutes)
'expiration' => 60 * 24 * 30,  // 30 days

// Automatically prune expired tokens (schedule this)
Schedule::command('sanctum:prune-expired --hours=24')->daily();

Passport — Rotate Refresh Tokens

// app/Providers/AuthServiceProvider.php
Passport::enableRotateRefreshTokens();
// Each time a refresh token is used, a new one is issued and the old one is revoked

The Definitive Recommendation for 2026

Start with Sanctum unless you have a specific reason for Passport.

For most developers in 2026, Laravel Sanctum is the correct starting point. It is the default API stack in Laravel 11 and 12. If you’re building a SaaS product with a React or Vue frontend and a Laravel backend, Sanctum handles everything you need in 30 minutes with zero OAuth complexity.

Choose Passport if any of these are true:

  • You are building an API meant for third-party developers. OAuth2 (via Passport) is more appropriate here.
  • You want other applications to have a “Sign in with [Your App]” button
  • You need machine-to-machine authentication (client credentials grant)
  • You need refresh tokens with automatic rotation
  • You’re operating in an enterprise context with OAuth2 compliance requirements

If you already have Passport set up, there is probably no reason to revert to Sanctum. However, if you’ve built your SPA using Sanctum and later want to also integrate third-party services, the changes to the code are minimal — you’ll change a few lines in your classes and route file. The only downside besides a bit of work is that sessions cannot be transferred, so all users will need to re-authenticate.

The decision is not permanent, but it’s easier to start simple and add complexity than to strip it out. Sanctum now, Passport if the requirements demand it.


Quick Reference: The Commands You’ll Actually Use

# ── SANCTUM ──────────────────────────────────────────────
# Install
php artisan install:api                  # Laravel 11/12 — installs Sanctum by default

# Prune expired tokens (add to scheduler)
php artisan sanctum:prune-expired --hours=24


# ── PASSPORT ─────────────────────────────────────────────
# Install
php artisan passport:install             # generates keys + default clients
php artisan passport:install --uuids     # use UUIDs instead of integers for client IDs
php artisan passport:install --force     # re-generate keys (rotates secrets)

# Key management
php artisan passport:keys                # generate encryption keys only
php artisan passport:keys --force        # regenerate (invalidates all tokens)

# Client management
php artisan passport:client --personal   # create personal access client
php artisan passport:client --password   # create password grant client (deprecated)
php artisan passport:client --client     # create client credentials client
php artisan passport:client --name="App" --redirect="https://app.com/callback"

# List clients
php artisan passport:list-clients

# Purge expired tokens
php artisan passport:purge               # remove expired and revoked tokens
php artisan passport:purge --revoked     # revoked only
php artisan passport:purge --expired     # expired only

Final Thoughts

The Sanctum vs Passport question is really a first-party vs third-party question dressed up in implementation details. If you own both the client and the API, Sanctum is almost certainly the right tool. If you’re building a platform where external developers or services need to access your users’ data through an industry-standard OAuth2 flow, Passport is the right tool.

The good news: both are mature, well-maintained, and production-ready in 2026. The ecosystem has gotten clearer about when each is appropriate, and Laravel’s own defaults reflect that clarity — Sanctum ships by default, Passport is there when you need the full OAuth2 surface.

Pick the simplest tool that handles your actual requirements. Upgrade when requirements change. That’s the right way to do auth in Laravel.

Leave a Reply

Your email address will not be published. Required fields are marked *