DEV Community

Cover image for Laravel Sanctum vs Passport vs Fortify: The 2026 Decision Tree
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Sanctum vs Passport vs Fortify: The 2026 Decision Tree


A team I worked with shipped a Vue SPA backed by a Laravel API. They picked Passport because someone on the team had used it three years ago. Six months later, they're maintaining a full OAuth2 server, four database tables of grants and scopes they never query, and a refresh-token flow that nobody on the team can draw on a whiteboard. The Vue app does first-party login. They never had a third-party client. They just needed cookies.

This is the Laravel auth tax. Three packages, three jobs, and the names don't tell you which is which.

The three packages in one paragraph each

Sanctum issues two things: stateful session cookies for first-party SPAs, and opaque API tokens for mobile apps or scripts. No OAuth2. No scopes that anyone outside your team uses. It's the boring choice and it's right almost every time.

Passport is a full OAuth2 server. Authorization codes, refresh tokens, PKCE, client credentials, the lot. You install it when you're issuing credentials to clients you don't control: third-party developers building against your API, partners with their own apps. If that's not your situation, you're paying for machinery you'll never run.

Fortify has nothing to do with tokens. It's a headless backend for user flows: register, login, password reset, email verification, two-factor. No views, no routes for your SPA, just controllers and actions you point your frontend at. Fortify and Sanctum stack. Passport and Fortify also stack. They're orthogonal.

Sanctum: SPA cookies + simple API tokens

Sanctum's killer feature is that it does one obvious thing well. For a first-party SPA on the same top-level domain as your API, you use Laravel's session cookie with CSRF protection. For everything else, a React Native app, a CLI, a partner integration where you control both ends, you mint opaque database-backed tokens.

The SPA setup that actually works in Laravel 12:

// config/sanctum.php
return [
    'stateful' => explode(',', env(
        'SANCTUM_STATEFUL_DOMAINS',
        'localhost,localhost:3000,127.0.0.1,app.example.com'
    )),

    'guard' => ['web'],

    'expiration' => null, // sessions handle expiry

    'middleware' => [
        'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
        'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
        'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
    ],
];
Enter fullscreen mode Exit fullscreen mode
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->statefulApi();
    $middleware->api(prepend: [
        \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    ]);
})
Enter fullscreen mode Exit fullscreen mode
// config/cors.php (the part everyone gets wrong)
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
    'allowed_methods' => ['*'],
    'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
    'allowed_headers' => ['*'],
    'supports_credentials' => true, // without this, cookies never arrive
];
Enter fullscreen mode Exit fullscreen mode
SESSION_DOMAIN=.example.com
SANCTUM_STATEFUL_DOMAINS=app.example.com
FRONTEND_URL=https://app.example.com
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
Enter fullscreen mode Exit fullscreen mode

The SPA hits GET /sanctum/csrf-cookie once on boot, Laravel sets XSRF-TOKEN as a readable cookie, the SPA echoes it back as the X-XSRF-TOKEN header on writes. From there, the session cookie does the work.

The gotcha: SESSION_DOMAIN must start with a dot if your API and SPA live on different subdomains (.example.com), and both must be HTTPS in production or SESSION_SECURE_COOKIE will silently drop the cookie. If your login endpoint returns 200 but the next request comes back unauthenticated, this is what bit you. The browser DevTools "Application → Cookies" panel is your friend.

For tokens, the API surface is two lines:

$token = $user->createToken('mobile-app', ['orders:read', 'orders:write']);
return ['token' => $token->plainTextToken];
Enter fullscreen mode Exit fullscreen mode

The "abilities" array is Sanctum's lightweight scope system. It's not OAuth2 scopes. It's a string array you check yourself with $user->tokenCan('orders:write'). That's a feature. You don't need a discovery endpoint.

Passport: full OAuth2 server. When you actually need it.

Passport is right when you're the identity provider for clients you don't run. Concrete examples: a SaaS where customers build their own integrations against your API, an enterprise product where partners need OAuth-compliant credentials for compliance audits, a public API where developers register apps in a portal and get a client ID and secret.

If you can answer "who calls my API" with "my own apps and a handful of trusted partners we share a secret with," you don't need Passport. A Sanctum token does the job and nobody has to learn the OAuth2 spec.

Passport in Laravel 12 ships as a separate laravel/passport package, runs its own migrations (oauth_clients, oauth_auth_codes, oauth_access_tokens, oauth_refresh_tokens), and exposes the standard endpoints:

php artisan passport:install --uuids
php artisan passport:client --public # for PKCE-only public clients
Enter fullscreen mode Exit fullscreen mode
// app/Models/User.php
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;
}
Enter fullscreen mode Exit fullscreen mode
// config/auth.php (Passport guard)
'guards' => [
    'web' => ['driver' => 'session', 'provider' => 'users'],
    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],
Enter fullscreen mode Exit fullscreen mode

You get /oauth/authorize, /oauth/token, /oauth/clients. Third-party developers register an app, get a client ID, redirect users to your authorize URL, receive an authorization code, exchange it for an access token. The flow your users see is the "Sign in with Acme" button on someone else's website.

The cost: every token issuance is a JWT signing operation (Passport uses RSA keys by default), every introspection hits the database, and if you turn on refresh tokens you're committing to revocation flows that you have to think about under attack. There's also no good way to "log a user out everywhere" without iterating their tokens. Sanctum has the same problem but with one table instead of four.

A sentence I'd write on a sticky note above the desk: if you wouldn't publish API docs on a public developer portal, you don't need Passport.

Fortify: backend for user flows

Fortify is the package nobody talks about because it doesn't issue tokens. It owns the boring parts: registration, login, password reset, password confirmation, email verification, two-factor with recovery codes. No Blade views. No frontend coupling. You wire your SPA or your mobile app to its endpoints and Fortify runs the actions.

The minimal setup that gives you a working user-flow backend for a Sanctum-based SPA:

// config/fortify.php
return [
    'guard' => 'web',
    'passwords' => 'users',
    'username' => 'email',
    'home' => '/dashboard',
    'prefix' => '',
    'domain' => null,
    'middleware' => ['web'],

    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        Features::emailVerification(),
        Features::updateProfileInformation(),
        Features::updatePasswords(),
        Features::twoFactorAuthentication([
            'confirm' => true,
            'confirmPassword' => true,
        ]),
    ],
];
Enter fullscreen mode Exit fullscreen mode
// app/Providers/FortifyServiceProvider.php
public function boot(): void
{
    Fortify::createUsersUsing(CreateNewUser::class);
    Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
    Fortify::resetUserPasswordsUsing(ResetUserPassword::class);

    RateLimiter::for('login', function (Request $request) {
        $throttleKey = Str::transliterate(
            Str::lower($request->input('email')) . '|' . $request->ip()
        );
        return Limit::perMinute(5)->by($throttleKey);
    });

    Fortify::authenticateUsing(function (Request $request) {
        $user = User::where('email', $request->email)->first();
        if ($user && Hash::check($request->password, $user->password)) {
            return $user;
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Now your SPA posts to /login, /register, /forgot-password, /two-factor-challenge. Fortify handles the password hashing, the rate limiting, the 2FA challenge, the password reset tokens. When login succeeds against the web guard, Sanctum's stateful middleware sees the session and your SPA is authenticated for every subsequent API call.

This is the unsung combo. Fortify owns how a user becomes a session. Sanctum owns how that session talks to the API. They don't compete.

The decision tree

Five questions. Answer them in order and stop at the first one that lands you somewhere.

1. Do third parties (developers, partners, customers) need to call your API on behalf of users they bring to you? Yes → Passport. No → continue.

2. Is your frontend a first-party SPA or a mobile app you ship? SPA on same top-level domain → Sanctum stateful cookies + Fortify. Mobile app or SPA on a different domain you can't share cookies with → Sanctum tokens + Fortify.

3. Do you need user flows like register, password reset, 2FA, email verification? Yes → add Fortify regardless of which token strategy you picked above. No (machine-to-machine only) → skip Fortify, use Sanctum tokens directly.

4. Are you serving a developer ecosystem where people register applications and need standard OAuth2 flows? Yes → Passport (you already answered this in question 1, but if you're rationalizing here, the answer is still yes).

5. Are you currently running Passport and answered "no" to question 1? Yes → you're in the migration story below. You're overpaying.

Most paths end at Sanctum + Fortify. That's the right answer for the 90% case.

The combo most apps actually need: Fortify + Sanctum

For a Laravel 12 SPA with login, registration, password reset, and 2FA, the install path is:

composer require laravel/fortify
composer require laravel/sanctum
php artisan install:api
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Register FortifyServiceProvider in bootstrap/providers.php, configure config/fortify.php and config/sanctum.php as above, set the CORS and session env vars correctly, and you're done. Your SPA calls GET /sanctum/csrf-cookie, then POST /login, then any authenticated endpoint with credentials.

Passport doesn't enter this picture. There's nothing it adds.

Migration story: Passport to Sanctum for an existing app

The team I mentioned at the top spent a Saturday on this. The migration plan, in order:

  1. Audit what calls your /oauth/token endpoint. Use access log analysis or add a logging middleware to Passport::routes and let it run for a week. If the only client IDs that show up are ones your team created, you're free.

  2. Add Sanctum alongside Passport. They can coexist. Change the api guard temporarily or use a separate guard:

'guards' => [
    'web' => ['driver' => 'session', 'provider' => 'users'],
    'api' => ['driver' => 'sanctum', 'provider' => 'users'],
    'api-legacy' => ['driver' => 'passport', 'provider' => 'users'],
],
Enter fullscreen mode Exit fullscreen mode
  1. For the SPA: configure the stateful middleware and CORS as shown above. Move your login endpoint from /oauth/token (password grant) to Fortify's /login. Your frontend now uses cookies, not Bearer tokens.

  2. For mobile apps and partner integrations: issue Sanctum tokens. Ship a new app version. Run both guards until your old Passport tokens have expired (the longest-lived refresh token sets your timeline).

  3. Once the old Passport access logs are quiet, remove the api-legacy guard, drop the laravel/passport package, and roll a migration to drop the four oauth_* tables.

The gotcha during migration: Passport's HasApiTokens trait and Sanctum's HasApiTokens trait have the same name. If your User model uses Passport's, Sanctum's createToken() won't be available. Switch the import:

// before
use Laravel\Passport\HasApiTokens;
// after
use Laravel\Sanctum\HasApiTokens;
Enter fullscreen mode Exit fullscreen mode

The method signatures differ slightly. createToken() in Sanctum returns a NewAccessToken with a plainTextToken property. In Passport it returns a PersonalAccessTokenResult. If any code path consumes the return value, audit those before the swap.

The reward at the end: four fewer tables, one fewer dependency, and a token system you can explain in a paragraph.

What to put on the team's wiki

If your auth doc is a 4000-word page about which package to use, you've over-thought it. The page should be three lines:

  • First-party SPA or mobile app, no third-party developers? Sanctum + Fortify.
  • Public developer API with third-party clients? Passport (and probably still Fortify for your own user flows).
  • Migrating from Passport and don't have third-party clients? Switch to Sanctum. Plan a quarter.

Auth isn't where Laravel apps should be losing time.


If this was useful

The auth-package question is one slice of a bigger pattern: Laravel ships strong defaults that fit 90% of cases, and the trouble starts when teams pick the wrong tool because they're optimizing for some future they'll never build. Decoupled PHP is about the architectural layer your codebase reaches for after it outgrows the framework defaults: what to keep close to Laravel, what to push behind a port, and how to know which is which.

What's the most expensive auth mistake you've seen on a Laravel project, and which package was involved? Drop a war story in the comments.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)