Laravel Authentication with Sanctum

API token auth with Sanctum, a complete protected-route example, and session auth for SPAs.

API tokens vs session-based auth

Laravel supports two different authentication styles, and Sanctum is built specifically to bridge both under one package:

  • Session-based auth — the traditional approach for a server-rendered app (or a single-page app served from the same domain as its API): a login request establishes a session, and the browser's session cookie authenticates every subsequent request automatically. This is what Laravel's web middleware group and Breeze/Jetstream scaffolding use by default.
  • Token-based auth — for a mobile app, a separate SPA on a different domain, or any client that isn't a browser holding a session cookie: the client authenticates once, receives a token, and sends it as an Authorization: Bearer <token> header on every subsequent request.

Sanctum is Laravel's first-party package for exactly this: lightweight API token issuance for the "not a same-domain browser session" case, without the heavier machinery (and complexity) of a full OAuth2 server like Laravel Passport.

Installing Sanctum

Bash
composer require laravel/sanctum
php artisan install:api

install:api (available in Laravel 11+) publishes Sanctum's migration, adds routes/api.php if it doesn't exist yet, and wires up the HasApiTokens trait's supporting table. Run the migration it generates:

Bash
php artisan migrate

Add the trait to the User model:

PHP
<?php
// app/Models/User.php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
    // ...
}

Issuing a token

PHP
<?php
// app/Http/Controllers/AuthController.php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required|string',
        ]);

        $user = User::where('email', $credentials['email'])->first();

        if (! $user || ! Hash::check($credentials['password'], $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['These credentials do not match our records.'],
            ]);
        }

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json(['token' => $token]);
    }
}
PHP
<?php
// routes/api.php

use App\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Route;

Route::post('/login', [AuthController::class, 'login']);

createToken('api-token') generates a new personal access token tied to that user, stores its hash in the personal_access_tokens table, and returns the plaintext token exactly once — it's never retrievable again after this response, so the client has to store it (typically in secure device storage, not localStorage in a browser SPA context).

A complete protected-route example

PHP
<?php
// routes/api.php

use App\Http\Controllers\PostController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/me', function (Request $request) {
        return $request->user();
    });

    Route::apiResource('posts', PostController::class);
});

auth:sanctum is the guard Sanctum registers — a request without a valid Authorization: Bearer <token> header gets a 401 Unauthenticated response automatically, before any route handler runs. Calling it from a client:

Bash
curl -X POST http://example.test/api/login \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "password": "s3cret-pass"}'
# {"token": "1|abcdef123456..."}

curl http://example.test/api/me \
  -H "Authorization: Bearer 1|abcdef123456..."

Sanctum for SPAs: the other mode

When the client is a first-party SPA served from a related domain (e.g. app.example.com calling api.example.com), Sanctum offers a second mode that skips tokens entirely and uses ordinary Laravel session cookies plus CSRF protection — configured via SANCTUM_STATEFUL_DOMAINS in .env and the EnsureFrontendRequestsAreStateful middleware. This avoids storing a bearer token in the SPA's JavaScript at all (a real XSS risk if a token sits in localStorage), at the cost of only working when the SPA and API share a registrable domain.

Token mode SPA (stateful) mode
Client type Mobile app, third-party API consumer First-party SPA on a related domain
Credential sent Authorization: Bearer <token> Session cookie + CSRF token
Token visible to JS Yes — has to be stored and attached manually No — the cookie is HttpOnly
Works cross-domain (unrelated domains) Yes No — requires a shared top-level domain

Common mistakes

  • Storing a Sanctum token in browser localStorage for a same-domain SPA instead of using Sanctum's stateful (cookie-based) mode — this exposes the token directly to any XSS vulnerability in the frontend, something an HttpOnly session cookie isn't vulnerable to.
  • Forgetting auth:sanctum on a route group meant to be protected — without it, the route ignores the Authorization header entirely and treats every request as unauthenticated (or worse, runs with no auth check at all if no other guard applies).
  • Expecting createToken()'s plaintext token to be retrievable later — only its hash is stored; losing the plaintext response means issuing a brand-new token, not recovering the old one.

Interview questions

Q: What problem is Sanctum designed to solve, and how is it different from Laravel Passport? Sanctum provides lightweight API token authentication (and, separately, cookie-based SPA authentication) without implementing a full OAuth2 server. Passport is a complete OAuth2 server implementation, appropriate when you genuinely need OAuth2 features like third-party application authorization and scoped access grants. For a typical first-party mobile app or SPA talking to your own API, Sanctum is the simpler, recommended default — Passport's extra complexity solves a problem most applications don't actually have.

Q: How does Sanctum authenticate a same-domain SPA differently from a mobile app or third-party API client? A mobile app or third-party client uses token mode — it receives a plaintext token from createToken() and sends it back as a Bearer header on every request. A first-party SPA served from a related domain instead uses Sanctum's stateful mode: ordinary Laravel session cookies (issued after a normal login) plus CSRF protection authenticate the SPA's requests, with no bearer token ever exposed to its JavaScript at all — configured via SANCTUM_STATEFUL_DOMAINS rather than token issuance.