The most common mistake I see with Laravel API authentication is reaching for the heavy option when the light one is what you actually need. Someone wants a login for their React frontend, installs Passport, generates OAuth2 signing keys, configures clients and scopes, and three days later has a fragile OAuth server protecting an app that has exactly one client: their own. The fix is almost always Sanctum. Passport and JWT solve real problems, but they are the wrong default. Here is how I decide between Sanctum, Passport, and JWT, and why Sanctum wins most of the time.
What is Sanctum actually for, and why is it the default?
Sanctum is the first thing I install on any new Laravel API, because it covers the two scenarios that account for the overwhelming majority of real apps. It does this with two completely separate modes, and understanding that split is the whole game.
Mode one is SPA cookie authentication. If your frontend (React, Vue, a Next.js app) lives on the same top-level domain as your API, Sanctum authenticates it with normal Laravel session cookies — no tokens at all. The browser hits /sanctum/csrf-cookie first to prime the CSRF token, then logs in, and from that point every request is stateful and cookie-backed. The win is that there is no bearer token sitting in localStorage waiting to be stolen by an XSS payload. I cover the cross-origin wiring for this exact setup in my Next.js + Laravel decoupled architecture post.
Mode two is simple API tokens. For a mobile app or a script or a trusted third-party client that cannot hold a browser session, Sanctum issues personal access tokens. You generate one, hand it over, and the client sends it as a bearer token. No OAuth ceremony, no grant types — just a token Sanctum stores hashed in your database.
How do I install and wire up Sanctum on Laravel 11/12?
On Laravel 11 and 12 there is a single command that scaffolds the whole API layer for you. It installs Sanctum, publishes its config and migration, and creates the routes/api.php file with the api route group registered. Run it and migrate:
# Laravel 11/12 — scaffolds Sanctum + routes/api.php in one step
php artisan install:api
# install:api already queues the Sanctum migration; run it
php artisan migrateThen add the HasApiTokens trait to your User model so it can mint tokens, issue one on login, and protect routes with the auth:sanctum middleware. The plainTextToken value is the only time you ever see the raw token — Sanctum stores just a hash of it, so you cannot recover it later.
<?php
// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
// routes/api.php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
Route::post('/login', function (Request $request) {
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
// Scope the token with abilities; the plain text value is shown once.
$token = $user->createToken('mobile', ['orders:read'])->plainTextToken;
return response()->json(['token' => $token]);
});
// Protected route — only valid Sanctum tokens get through.
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});When do I actually need Passport instead?
Passport is a full OAuth2 server. The only time I install it is when I am issuing tokens to external third-party developers who build their own apps against my API — the case where you genuinely need the authorization code grant, client credentials, refresh tokens, and per-client scopes. If you have ever integrated against the GitHub or Stripe API and registered an OAuth app, that is the experience Passport lets you provide. Install is more involved because it generates encryption keys and seeds OAuth clients:
composer require laravel/passport
php artisan migrate
# Generates the OAuth2 signing keys and creates default clients
php artisan passport:installIf you are not running an OAuth provider for outside developers, Passport is overhead you will pay for forever: signing keys to rotate, clients and scopes to manage, and a heavier mental model for every teammate who touches the auth layer. For a first-party app, it is the wrong tool.
Where does JWT fit, and what is the catch?
JWT is for stateless, service-to-service authentication — microservices where you explicitly do not want a session or a database lookup on every request. The token carries its own signed claims, so any service holding the public key can verify it without calling back to a central auth store. In Laravel I reach for firebase/php-jwt for hand-rolled signing, or php-open-source-saver/jwt-auth, the maintained fork of the long-abandoned tymon/jwt-auth, when I want guard integration.
The catch is the thing people forget until it bites them in production: revocation is hard. Because verification is stateless, a stolen JWT is valid until it expires — there is no row to delete. To kill a token early you need a denylist (typically in Redis), which quietly reintroduces the per-request lookup you adopted JWT to avoid. You also own signing and key rotation entirely. Keep lifetimes short and treat that denylist as mandatory, not optional.
So which one do I pick?
Strip away the religion and the decision is mechanical. Match your client to the row:
- First-party SPA on the same top-level domain — Sanctum cookie mode. No tokens to leak, CSRF handled for you.
- Mobile app or a simple/trusted API client — Sanctum API tokens. One trait, one createToken() call.
- You are an OAuth2 provider issuing tokens to external developers — Passport. This is the only case it earns its weight.
- Stateless service-to-service or microservices with no shared session — JWT, with a short lifetime and a denylist for revocation.
Pick the lightest mechanism your client demands — every layer of auth ceremony you add is a layer you, and everyone after you, will maintain forever.
What security details survive contact with production?
Whichever mechanism you choose, a few non-negotiables keep it from becoming the weak link. Keep token lifetimes short so a leaked token has a small blast radius. Scope every token with the minimum abilities it needs — Sanctum's createToken() takes an abilities array exactly for this, so a read-only mobile token can never mutate data. Store only hashed tokens, which Sanctum does by default and you must do by hand with JWT denylists. And rate-limit your auth endpoints aggressively, because an unthrottled /login or /oauth/token route is an open invitation to credential stuffing — Laravel's throttle middleware on those routes is the cheapest defense you will ever add. For the wider picture, run your API against my Laravel security checklist before you ship.
The honest summary after years of shipping Laravel APIs: start with Sanctum and only graduate to Passport or JWT when a concrete requirement forces your hand. Most of the time that requirement never arrives, and the team that resisted the urge to install an OAuth2 server for a single-client app is the one that sleeps better. Choose the smallest thing that works, lock down lifetimes and scopes, rate-limit the doors, and your authentication layer stops being the part of the codebase everyone is afraid to touch.

