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 (league/oauth2-server) issuing JWT access tokens

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)

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

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
  • 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_PREFIX adds 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_at is updated on use.

  • currentAccessToken()->delete() revokes one device; $user->tokens()->delete() all of them. Expiry is per token (expiresAt) or global (sanctum.expiration in minutes), and php artisan sanctum:prune-expired cleans 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
  • Axios-style clients read the XSRF-TOKEN cookie and send it back as X-XSRF-TOKEN automatically; fetch() needs that done by hand. Without it, the POST got 419, exactly as a form without @csrf would.

  • The session cookie only counted for requests from a stateful origin; the same cookie from another Referer was ignored. Front end and API on different subdomains need SESSION_DOMAIN=.example.com, the front end’s host in SANCTUM_STATEFUL_DOMAINS, and CORS with supports_credentials (CORS).

  • Abilities do not restrict SPA sessions: the POST /api/loans route requires abilities: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)

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

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 same state, which the app must compare to the one it sent.

  • Exchanging the code with a wrong code_verifier failed with invalid_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_token column in users holding the token in plain text, checked by the token guard (Stauffer ch. 13) means a database leak hands out working tokens. The default config/auth.php of current applications no longer defines that guard; Sanctum stores SHA-256 hashes, and a hand-made guard should do the same (as the api-key guard 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.