API Authentication: Sanctum and Passport
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with Sanctum 4.3 in the
Bookshelf application and Passport 13.8 (on league/oauth2-server 9.4) in a separate new application, since one
User model cannot use both packages' HasApiTokens traits. Browser logins with sessions are on
Authentication; OAuth 2 itself, independent of Laravel, is covered on
OAuth 2.0 and OpenID Connect.
Sanctum or Passport
| Sanctum | Passport | |
|---|---|---|
What it is |
Opaque API tokens stored in your database, plus cookie authentication for your own SPA |
A full OAuth 2 authorization server ( |
Clients |
Your own mobile app, CLI tools, scripts; your SPA on the same site |
Third-party applications that act on behalf of your users, machine-to-machine integrations |
Flows |
"Log in, get a token"; session cookies for the SPA |
Authorization code with PKCE, client credentials, device authorization, refresh tokens, personal access tokens |
Choose when |
Almost always for first-party clients |
Others must integrate with your API using OAuth (as with GitHub or Google) |
Source: Laravel — Passport or Sanctum?
php artisan install:api sets up either: it publishes routes/api.php (mounted at /api with the api middleware
group) and installs Sanctum, or Passport with --passport.
Sanctum API tokens
HasApiTokens on User adds createToken(). A token has a name (usually the device), abilities and an optional
expiry:
<?php
// routes/api.php (Bookshelf)
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\ValidationException;
Route::get('/user', fn (Request $request) => $request->user()->only('id', 'name'))->middleware('auth:sanctum');
// issue a token to a mobile app or script
Route::post('/tokens', function (Request $request) {
$data = $request->validate(['email' => 'required|email', 'password' => ['required'], 'device' => 'required|string|max:60']);
$user = User::where('email', $data['email'])->first();
if (! $user || ! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$token = $user->createToken($data['device'], abilities: ['loans:read'], expiresAt: now()->plus(days: 30));
return ['token' => $token->plainTextToken, 'expires_at' => $token->accessToken->expires_at];
})->middleware('throttle:6,1');
Route::middleware('auth:sanctum')->group(function () {
Route::get('/loans', fn (Request $request) => $request->user()->loans()->get(['id', 'book_id', 'due_at']))
->middleware('abilities:loans:read');
Route::post('/loans', fn () => ['created' => true])->middleware('abilities:loans:write');
Route::delete('/tokens/current', function (Request $request) {
$request->user()->currentAccessToken()->delete(); // log this device out
return response()->noContent();
});
});
Source: Laravel — Issuing API Tokens and Token Abilities
The abilities and ability middleware aliases are registered in bootstrap/app.php
(Laravel\Sanctum\Http\Middleware\CheckAbilities and CheckForAnyAbility). A session with curl, the token shortened:
$ curl -s -H 'Accept: application/json' -d 'email=ada@example.com&password=…&device=ada-phone' localhost:8000/api/tokens
{"token":"3|…","expires_at":"2026-11-04T21:24:08.000000Z"}
token shape: id 3 + | 48 chars
personal_access_tokens row: {"name":"ada-phone","abilities":"[\"loans:read\"]","expires_at":"2026-11-04 21:24:08","last_used_at":null}, token column 64 chars
$ curl -s -H "Authorization: Bearer $TOKEN" -w ' %{http_code}\n' localhost:8000/api/loans
[] 200
$ curl -s -H "Authorization: Bearer $TOKEN" -X POST -w ' %{http_code}\n' localhost:8000/api/loans
{"message": "Invalid ability provided."} 403
$ curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -w '%{http_code}\n' localhost:8000/api/tokens/current
204
$ curl -s -H "Authorization: Bearer $TOKEN" -w ' %{http_code}\n' localhost:8000/api/user
{"message":"Unauthenticated."} 401
Source: Laravel — Revoking Tokens and Token Expiration
-
The plain-text token is
<id>|<secret>: 40 random characters plus an 8-character CRC32 checksum (which lets secret scanners recognise leaked tokens;SANCTUM_TOKEN_PREFIXadds a recognisable prefix). It is shown once; the database stores only its SHA-256 hash (the 64-character column), so a database leak does not leak usable tokens. -
Abilities are checked per route (
abilities:requires all,ability:any) or in code with$request->user()->tokenCan('loans:write');last_used_atis updated on use. -
currentAccessToken()->delete()revokes one device;$user->tokens()->delete()all of them. Expiry is per token (expiresAt) or global (sanctum.expirationin minutes), andphp artisan sanctum:prune-expiredcleans up.
Sanctum SPA authentication
A JavaScript front end served from the same site does not need tokens: it uses the normal session cookie, protected by
CSRF, and Sanctum lets that session authenticate /api routes. $middleware->statefulApi() in bootstrap/app.php
enables it for requests whose Origin or Referer is in SANCTUM_STATEFUL_DOMAINS (by default localhost,
127.0.0.1 with common ports, and the host of APP_URL). The flow, with curl playing the SPA:
$ curl -s -c jar -b jar -H 'Referer: http://localhost:8000/' -w 'csrf-cookie %{http_code}\n' localhost:8000/sanctum/csrf-cookie
csrf-cookie 204 # sets XSRF-TOKEN and laravel-session
$ curl -s -c jar -b jar -H 'Referer: http://localhost:8000/' -H "X-XSRF-TOKEN: $XSRF" -d 'email=ada@example.com&password=…' -w 'login %{http_code}\n' localhost:8000/login
login 302
$ curl -s -b jar -H 'Referer: http://localhost:8000/' localhost:8000/api/user
{"id":1,"name":"Ada Reader"} 200
$ curl -s -b jar localhost:8000/api/user # no Referer/Origin: not treated as the SPA
{"message":"Unauthenticated."} 401
$ curl -s -b jar -H 'Referer: http://evil.example/' localhost:8000/api/user
{"message":"Unauthenticated."} 401
$ curl -s -b jar -H 'Referer: http://localhost:8000/' -X POST localhost:8000/api/loans
419
$ curl -s -b jar -H 'Referer: http://localhost:8000/' -H "X-XSRF-TOKEN: $XSRF" -X POST localhost:8000/api/loans
{"created":true} 200
Source: Laravel — SPA Authentication
-
Axios-style clients read the
XSRF-TOKENcookie and send it back asX-XSRF-TOKENautomatically;fetch()needs that done by hand. Without it, thePOSTgot419, exactly as a form without@csrfwould. -
The session cookie only counted for requests from a stateful origin; the same cookie from another
Refererwas ignored. Front end and API on different subdomains needSESSION_DOMAIN=.example.com, the front end’s host inSANCTUM_STATEFUL_DOMAINS, and CORS withsupports_credentials(CORS). -
Abilities do not restrict SPA sessions: the
POST /api/loansroute requiresabilities:loans:write, and the cookie-authenticated request passed. A session is treated as holding every ability, so authorization for the SPA must come from policies (Authorization: Gates and Policies), not from token abilities.
Testing:
<?php
// tests/Feature/ApiTokenTest.php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
uses(RefreshDatabase::class);
it('limits a token to its abilities', function () {
Sanctum::actingAs(User::factory()->create(), ['loans:read']);
$this->getJson('/api/loans')->assertOk();
$this->postJson('/api/loans')->assertForbidden();
});
it('rejects an expired token', function () {
$user = User::factory()->create();
$token = $user->createToken('old-phone', ['loans:read'], now()->minus(minutes: 1))->plainTextToken;
$this->withToken($token)->getJson('/api/user')->assertUnauthorized();
});
it('stores only a hash of the token', function () {
$plain = User::factory()->create()->createToken('cli')->plainTextToken;
[$id, $secret] = explode('|', $plain);
$this->assertDatabaseHas('personal_access_tokens', ['id' => $id, 'token' => hash('sha256', $secret)]);
$this->assertDatabaseMissing('personal_access_tokens', ['token' => $secret]);
});
$ php artisan test --filter=ApiTokenTest
✓ it limits a token to its abilities 0.22s
✓ it rejects an expired token 0.02s
✓ it stores only a hash of the token 0.01s
Tests: 3 passed (5 assertions)
Source: Laravel — Sanctum Testing
Passport: an OAuth 2 server
php artisan install:api --passport installs Passport, runs its migrations (clients, auth codes, access and refresh
tokens, device codes) and generates the RSA key pair in storage/oauth-*.key that signs access tokens. Configuration
goes in a service provider:
<?php
// app/Providers/AppServiceProvider.php, in boot() (Passport application)
use Laravel\Passport\Passport;
Passport::tokensCan([
'loans:read' => 'See your loans',
'loans:write' => 'Borrow and return books',
]);
Passport::authorizationView('oauth.authorize'); // Passport 13 ships no views
Passport::tokensExpireIn(now()->plus(hours: 1));
Passport::refreshTokensExpireIn(now()->plus(days: 30));
<?php
// routes/api.php (Passport application); config/auth.php has 'api' => ['driver' => 'passport', 'provider' => 'users']
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Passport\Http\Middleware\CheckToken;
use Laravel\Passport\Http\Middleware\EnsureClientIsResourceOwner;
Route::get('/user', fn (Request $request) => $request->user()->only('id', 'email'))->middleware(['auth:api', CheckToken::using('loans:read')]);
Route::get('/catalogue-sync', fn () => ['books' => 4])->middleware(EnsureClientIsResourceOwner::class); // machine-to-machine
Source: Laravel — Passport Installation and Token Scopes
User implements Laravel\Passport\Contracts\OAuthenticatable and uses Passport’s HasApiTokens. The private key
must stay out of version control (PASSPORT_PRIVATE_KEY can load it from the environment), and every server must use
the same key pair.
Client credentials
A machine client (another backend) authenticates as itself, with no user. php artisan passport:client --client
prints the client ID and a secret that is stored hashed and shown only once:
$ php artisan passport:client --client --name="Catalogue sync"
INFO New client created successfully.
Client ID ............................. 01a10df5-12fb-7290-8fb7-1f3f2208a883
Client Secret ..................... …
WARN The client secret will not be shown again, so don't lose it!
$ curl -s -d "grant_type=client_credentials&client_id=$ID&client_secret=$SECRET&scope=" localhost:8020/oauth/token
{'token_type': 'Bearer', 'expires_in': 3600, 'access_token': 'eyJ0eXAiOiJK… (1073 chars, JWT)'}
JWT claims: {'aud': '01a10df5-42d1-7236-a774-0d367ea683d2', 'sub': '01a10df5-42d1-7236-a774-0d367ea683d2', 'scopes': []}
$ curl -s -H "Authorization: Bearer $AT" localhost:8020/api/catalogue-sync
{"books":4} 200
$ curl -s -H "Authorization: Bearer $AT" localhost:8020/api/user
{"message":"Unauthenticated."} 401
The access token is a signed JWT (about 1 KB) whose subject is the client itself, so the user-protected /api/user
refused it while EnsureClientIsResourceOwner accepted it. Tokens expired after the configured hour. The grant in
general is on Client Credentials and Client
Authentication.
Authorization code with PKCE
A mobile or single-page app acting for a user uses the authorization code grant with PKCE: a public client (no
secret), created with php artisan passport:client --public. Passport 13 has no built-in consent screen, so the
application provides the view registered with authorizationView():
{{-- resources/views/oauth/authorize.blade.php --}}
<h1>{{ $client->name }} wants to access your account</h1>
<ul>@foreach ($scopes as $scope)<li>{{ $scope->description }}</li>@endforeach</ul>
<form method="POST" action="{{ route('passport.authorizations.approve') }}">
@csrf
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->getKey() }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button>Authorize</button>
</form>
The flow was driven by a Python script with curl playing the browser and the app: log in, generate a
code_verifier and its S256 code_challenge, open /oauth/authorize, approve, take the code from the redirect, and
exchange it:
login: 302
consent page: Bookshelf mobile wants to access your account | ['See your loans']
redirect: http://localhost:9000/callback state ok: True code length: 884
wrong verifier: invalid_grant - The provided authorization grant (e.g., authorization code,
token response keys: ['access_token', 'expires_in', 'refresh_token', 'token_type'] expires_in 3600
api/user: {"id":1,"email":"ada@example.com"}
refreshed: True ; old refresh token again: invalid_grant
# a second authorization request for the same client and scopes, while the user still has a token
302 http://localhost:9000/callback?code=…&state=s1
-
The consent page listed the scope descriptions from
tokensCan(); the redirect carried the samestate, which the app must compare to the one it sent. -
Exchanging the code with a wrong
code_verifierfailed withinvalid_grant: an intercepted code is useless without the verifier that only the app has. The code remained usable with the right verifier afterwards. -
Refreshing returned a new refresh token and revoked the old one (rotation), so a stolen refresh token stops working once the legitimate app refreshes.
-
The second authorization request skipped the consent screen and redirected straight back with a code, because the user already had a valid token for that client and those scopes.
The device authorization grant (passport:client --device) serves TVs and CLIs without a browser, and personal
access tokens ($user->createToken('cli', ['loans:read'])) give users Passport tokens without an OAuth dance. The
implicit and password grants are disabled by default; OAuth 2.1 drops both
(Legacy Implicit and Password Grants). The PKCE flow in
general is on Authorization Code and PKCE.
Where older books and tutorials are outdated
-
An
api_tokencolumn inusersholding the token in plain text, checked by thetokenguard (Stauffer ch. 13) means a database leak hands out working tokens. The defaultconfig/auth.phpof current applications no longer defines that guard; Sanctum stores SHA-256 hashes, and a hand-made guard should do the same (as theapi-keyguard on Authentication does). -
Passport for every API (common before Sanctum was released in early 2020) adds an OAuth server most first-party clients do not need.
-
The password grant for mobile apps is replaced by the authorization code grant with PKCE.
-
Passport’s built-in Vue components and views were removed; Passport 13 expects the application’s own consent screen.
-
Passport::routes()in a service provider (Passport 10 and earlier) is unnecessary: routes are registered automatically.