SPA Authentication with Laravel 7 and Sanctum
How Laravel Sanctum solves SPA authentication, and why its cookie-based approach is cleaner than token-based alternatives.
Authentication has always been an uncomfortable question when building SPAs: should I use sessions or tokens? Laravel Passport is powerful for large projects but brings a lot of overhead. JWT libraries get the job done, but each one has its own quirks. Sanctum, which shipped as an official first-party package with Laravel 7, changed that equation.
What was the problem?
Imagine you want to manage sessions between a Vue SPA and a Laravel API running on the same domain. Classic web authentication does not work here: the SPA runs in a separate process and the laravel_session cookie is not automatically attached to API requests. With a token-based approach you have to figure out where to store the token securely (localStorage is dangerous, an httpOnly cookie requires separate plumbing) and deal with token refresh on top of that.
Sanctum answers two distinct scenarios: cookie-based sessions for same-site origins, and personal access tokens for mobile or third-party clients. For an SPA, the first path is the cleaner one.
Installation and configuration
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Add Sanctum’s middleware to the api middleware group:
// app/Http/Kernel.php
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
Add your SPA’s origin to the stateful array in config/sanctum.php:
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),
Login flow
On the SPA side, first fetch the CSRF cookie, then send the credentials:
// First, grab the CSRF cookie
await axios.get('/sanctum/csrf-cookie');
// Then send the login request
await axios.post('/login', {
email: this.email,
password: this.password,
});
The /sanctum/csrf-cookie request causes Sanctum to drop an XSRF-TOKEN cookie in the browser. Axios automatically reads that cookie and forwards it as an X-XSRF-TOKEN header on every subsequent request. Laravel validates that header to prevent CSRF attacks.
When login succeeds, Laravel starts a server-side session and sends the session cookie back to the browser. Every API request that carries that cookie is treated as authenticated from that point on.
Protected routes use the auth:sanctum guard:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn () => auth()->user());
Route::apiResource('posts', PostController::class);
});
Comparison with the token-based approach
Why is Sanctum’s cookie model better for SPAs? A few concrete reasons:
You have to find a safe place to store a token. localStorage is exposed to XSS attacks; an httpOnly cookie is safe, but then you are already using a cookie anyway. Because Sanctum piggybacks on Laravel’s built-in session infrastructure, you never have to make that call yourself.
There is no token refresh mechanism to worry about. Sanctum’s cookie model behaves like a standard Laravel session — session lifetime is managed entirely on the server.
The same-domain constraint matters. The SPA and API can live on different subdomains (app.example.com and api.example.com), but not on entirely different root domains. Once you step outside that boundary, you need to switch to Sanctum’s token model.
Subdomain configuration and a common pitfall
When everything works in development but authentication suddenly breaks in production, the first thing to check is the SANCTUM_STATEFUL_DOMAINS variable. Forgetting to include the port number in the .env file is an extremely common mistake:
# Wrong
SANCTUM_STATEFUL_DOMAINS=app.example.com
# Correct (for a local dev environment)
SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:3000
Also, if you are using subdomains, you need to set SESSION_DOMAIN to .example.com (leading dot included); otherwise the cookie is only sent to api.example.com and not to app.example.com.
Logout and session management
To log out, send a POST request to /logout — Laravel invalidates the session:
Route::post('/logout', function (Request $request) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
});
Sanctum is a clean, Laravel-native answer to the SPA authentication problem. It is nowhere near as comprehensive as Passport — if you need full OAuth2 flows, it is the wrong tool. But for securing sessions between your own SPA and your own API, it is exactly the right scope.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.