What Sanctum Is (and Is Not)

Sanctum solves two problems with one package:

  • Personal access tokens. Long-lived bearer tokens for mobile apps, CLI tools and third-party integrations. Stored hashed in a database table.
  • SPA authentication. Cookie and session based auth for a first-party JavaScript front end on the same top-level domain. No tokens involved at all.

Sanctum is not an OAuth2 server. If you need to let other companies’ applications request access to your users’ data with an authorisation screen, refresh tokens and grant types, you want Passport. For everything else, Sanctum is simpler and almost certainly enough.

Both modes go through the same auth:sanctum middleware. Sanctum checks for a session cookie first and falls back to the Authorization: Bearer header — which is why one guard serves both use cases.

Installation

# Laravel 11 / 12
php artisan install:api

# Laravel 10 and earlier
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

install:api installs Sanctum, publishes the migration, creates routes/api.php and registers it. Then add the trait to your user model:

use Laravel\Sanctum\HasApiTokens;

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

Protect routes with the guard:

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $r) => $r->user());
    Route::apiResource('posts', PostController::class);
});

API Tokens for Mobile and Third Parties

A login endpoint issues the token. Note that the plain-text value exists exactly once — only a hash is stored.

use Illuminate\Validation\ValidationException;

public function login(Request $request)
{
    $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' => ['These credentials do not match our records.'],
        ]);
    }

    return response()->json([
        'token' => $user->createToken($request->device_name)->plainTextToken,
    ]);
}

The returned string looks like 7|laVGkQ9zR2... — the numeric prefix is the token’s database ID, the rest is the secret. Sanctum uses the prefix to find the row and then hashes the remainder to verify it.

plainTextToken is available only on the response from createToken(). There is no way to read it back later — if a user loses it, you issue a new one and revoke the old.

Throttle the login route. Without it you have handed attackers an unlimited-rate credential oracle:

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:5,1');

Using a Token

curl https://api.example.com/api/user \
  -H "Authorization: Bearer 7|laVGkQ9zR2..." \
  -H "Accept: application/json"
// JavaScript client
const res = await fetch('https://api.example.com/api/user', {
  headers: {
    'Authorization': `Bearer ${token}`,
    'Accept': 'application/json',
  },
});

Inside the application, the authenticated user and the token itself are both available:

$request->user();                       // the User model
$request->user()->currentAccessToken(); // the PersonalAccessToken model
$request->user()->currentAccessToken()->name;
$request->user()->currentAccessToken()->last_used_at;

Abilities — Scoping What a Token Can Do

A token that can do everything is a liability. Abilities let you issue narrow tokens — a read-only reporting integration, a mobile app that cannot delete accounts.

$token = $user->createToken('reporting-integration', ['posts:read', 'stats:read']);
$token = $user->createToken('mobile-app', ['posts:read', 'posts:create', 'posts:update']);
$token = $user->createToken('admin-cli', ['*']);   // everything
// Check in a controller
if (! $request->user()->tokenCan('posts:create')) {
    abort(403, 'This token cannot create posts.');
}

// Or enforce it on the route with the ability middleware
Route::post('/posts', [PostController::class, 'store'])
    ->middleware(['auth:sanctum', 'abilities:posts:create']);

// ability = any one of them; abilities = all of them
Route::delete('/posts/{post}', [PostController::class, 'destroy'])
    ->middleware(['auth:sanctum', 'ability:posts:delete,admin']);

Abilities are not authorisation. A token with posts:update still needs a policy check to confirm this user may edit this post. Abilities limit the token; policies limit the user. You need both.

Revoking and Expiring Tokens

// Revoke the token used for the current request (logout)
$request->user()->currentAccessToken()->delete();

// Revoke everything (logout everywhere / password change)
$request->user()->tokens()->delete();

// Revoke one specific device
$request->user()->tokens()->where('id', $tokenId)->delete();

// List active tokens for a settings screen
$request->user()->tokens()->get(['id', 'name', 'abilities', 'last_used_at']);

Set a global expiry in config/sanctum.php, or a per-token one at creation:

// config/sanctum.php — minutes; null means never expire
'expiration' => 60 * 24 * 30,   // 30 days
$user->createToken('short-lived', ['*'], now()->addHours(2));

Expired rows are not removed automatically. Schedule the prune command:

// routes/console.php  (Laravel 11/12)
Schedule::command('sanctum:prune-expired --hours=24')->daily();

SPA Authentication With Cookies

For a first-party Vue, React or Inertia front end on the same top-level domain, do not use tokens. Use Sanctum’s SPA mode: normal Laravel sessions, normal CSRF protection, an HttpOnly cookie the JavaScript cannot read — and therefore no token for an XSS payload to steal.

The flow has three steps:

// 1. Hit the CSRF endpoint — sets the XSRF-TOKEN cookie
await axios.get('/sanctum/csrf-cookie');

// 2. Log in against the normal web session route
await axios.post('/login', { email, password });

// 3. Every subsequent request is authenticated by the session cookie
const { data } = await axios.get('/api/user');

Axios reads the XSRF-TOKEN cookie and sends it back as X-XSRF-TOKEN automatically. With fetch you have to do it yourself:

function csrfToken() {
  return decodeURIComponent(
    document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? ''
  );
}

await fetch('/api/posts', {
  method: 'POST',
  credentials: 'include',              // required — sends cookies
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'X-XSRF-TOKEN': csrfToken(),
  },
  body: JSON.stringify({ title: 'Hello' }),
});

CORS and Cookie Configuration

Nearly every “Sanctum returns 401 in my SPA” report comes down to one of these four settings.

# .env
SESSION_DRIVER=cookie
SESSION_DOMAIN=.example.com          # leading dot — shared across subdomains
SANCTUM_STATEFUL_DOMAINS=app.example.com,localhost:5173
APP_URL=https://api.example.com
// config/cors.php
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
    'allowed_methods'    => ['*'],
    'allowed_origins'    => ['https://app.example.com', 'http://localhost:5173'],
    'allowed_headers'    => ['*'],
    'supports_credentials' => true,     // MUST be true for cookie auth
];
SymptomUsual cause
401 on every requestSPA domain missing from SANCTUM_STATEFUL_DOMAINS
419 Page ExpiredCSRF cookie not fetched, or X-XSRF-TOKEN not sent
Cookie not stored by browsersupports_credentials false, or SESSION_DOMAIN wrong
Works locally, fails in productionFront end and API on different top-level domains — use tokens instead

SPA mode requires the front end and the API to share a top-level domain (app.example.com and api.example.com is fine; myapp.vercel.app and api.example.com is not). Cross-site cookies are blocked by modern browsers. If the domains genuinely differ, use bearer tokens.

Sanctum vs Passport vs JWT

SanctumPassportJWT package
ProtocolSimple tokens + sessionsFull OAuth2Signed JWT
Setup effortMinutesHoursModerate
RevocationInstant — delete the rowInstantHard — needs a denylist
StatelessNo (DB lookup per request)NoYes
Third-party clientsNot reallyYes — that is the pointPossible
First-party SPAExcellentOverkillWorkable but riskier

Choose Sanctum unless you are building an OAuth2 provider. The database lookup per request costs a fraction of a millisecond and buys you instant revocation, which JWT cannot give you without reintroducing the same lookup.

Security Checklist

  • HTTPS everywhere. A bearer token over plain HTTP is a password in the clear.
  • Throttle authentication routes. throttle:5,1 on login and password reset, minimum.
  • Scope every token. Never issue ['*'] to a third party.
  • Set an expiry. A token that never expires is a permanent credential in someone’s laptop backup.
  • Revoke on password change. Call $user->tokens()->delete() so a stolen token dies with the old password.
  • Store tokens in the OS keychain on mobile, never in plain preferences.
  • Prefer SPA mode for first-party front ends. An HttpOnly cookie is not readable by injected JavaScript; a token in localStorage is.
  • Show users their active tokens with last_used_at, and let them revoke individually.