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
webmiddleware 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
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:
php artisan migrate
Add the trait to the User model:
<?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
// 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
// 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
// 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:
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
localStoragefor 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 anHttpOnlysession cookie isn't vulnerable to. - Forgetting
auth:sanctumon a route group meant to be protected — without it, the route ignores theAuthorizationheader 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.