Login works, then every request after it returns 419 or 401. Or it works locally and breaks on the first deploy. Cookie auth for a Vue SPA is five settings that must agree; here they are in order, with the token path kept for the clients that actually need it.
TL;DR
php artisan install:api, then$middleware->statefulApi().- List the SPA origin in
SANCTUM_STATEFUL_DOMAINS; turn onsupports_credentials. - Login is a web route. Call
GET /sanctum/csrf-cookiebefore the first POST. - Axios:
withCredentials+withXSRFToken. 419 = CSRF, 401 = no session. - Bearer tokens are for phones, scripts and third parties, not your own SPA.
“Laravel Sanctum Vue” tutorials still tell you to store a bearer token in localStorage for an app you wrote yourself. Sanctum’s docs say the opposite. A first-party SPA uses the session cookie. Tokens are for a phone, a script, or someone else’s server.
If the screen is an Inertia page from the Vue starter kit, you already have login. Read which stack to use before you add a second auth system. This guide is for a Vue app that calls a Laravel API.
- Install the API and turn on stateful requests.
- Line up domains, cookies and CORS.
- Put login on a web route.
- Configure Axios and fetch the CSRF cookie first.
- Test it.
Install the API#
php artisan install:apiThat command installs Sanctum and publishes the API routes. Then allow cookie authentication from your own front end:
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})statefulApi() is what makes a request from your SPA use the session, while a request with an Authorization: Bearer header can still use a token.
Domains, cookies, and CORS#
The SPA and the API must share a top-level domain. app.example.com and api.example.com work. localhost:5173 calling localhost:8000 works only if the port is listed.
Add the SPA origin, including the port, to SANCTUM_STATEFUL_DOMAINS. For Vite that is often localhost:5173. For Nuxt it is often localhost:3000. Also include the Laravel host you actually open, such as localhost:8000.
If the SPA is on another origin, publish CORS and allow credentials:
php artisan config:publish cors'supports_credentials' => true,For two real subdomains, set the session cookie on the parent:
'domain' => '.example.com',Gotcha: a leading dot on localhost
Leave SESSION_DOMAIN empty when everything is localhost. A leading dot on localhost is a common way to get a cookie that never sticks.
Tip: always ask for JSON
Send Accept: application/json on API calls. Without it, a failed login can come back as a redirect to an HTML page instead of a 422.
The login route is a web route#
Heads up: not routes/api.php
The session and the CSRF check live on the web middleware group. Put login there, not in routes/api.php.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
Route::post('/login', function (Request $request) {
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => 'These credentials do not match our records.',
]);
}
$request->session()->regenerate();
return response()->noContent();
});
Route::post('/logout', function (Request $request) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
});Protect the JSON routes with the sanctum guard:
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');Laravel Fortify can own /login instead of the closure above. Use it when you also need registration, password reset, and two-factor. The cookie rules do not change.
The Vue side#
Inertia 3 no longer bundles Axios. A separate SPA installs it:
npm install axios// resources/js/api.ts, or the SPA's src/api.ts
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? "",
withCredentials: true,
withXSRFToken: true,
headers: { Accept: "application/json" },
});Tip
withXSRFToken makes Axios copy the XSRF-TOKEN cookie into the X-XSRF-TOKEN header. That is the CSRF check.
export async function login(email: string, password: string) {
await api.get("/sanctum/csrf-cookie");
await api.post("/login", { email, password });
}
export async function currentUser() {
const { data } = await api.get("/api/user");
return data;
}| Status | Meaning | Fix |
|---|---|---|
| 419 | CSRF cookie missing or not stored | Call /sanctum/csrf-cookie first; check SESSION_DOMAIN, supports_credentials |
| 401 | Request arrived without a session | Check SANCTUM_STATEFUL_DOMAINS, then log in again |
| CORS error | Preflight blocked | CORS guide |
Call /sanctum/csrf-cookie before the first POST. Laravel sets the cookie on that response. A later POST without it returns 419. A POST with a valid cookie but no session returns 401 from auth:sanctum. Send the user back to the login page on either of those.
A 419 after a deploy is a CSRF mismatch. SESSION_DOMAIN or a missing supports_credentials often causes it, because the cookie is not stored. SANCTUM_STATEFUL_DOMAINS decides whether /api/* gets the session middleware. If the SPA origin is missing there, auth:sanctum returns 401, not 419. /login is on the web group either way. It is rarely “Sanctum is broken.”
Tokens, only for the other clients#
A phone app cannot rely on the SPA cookie. Issue a personal access token and send it as Authorization: Bearer.
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}use App\Models\User;
use Illuminate\Support\Facades\Hash;
Route::post('/sanctum/token', function (Request $request) {
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
'device_name' => ['required'],
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => 'The provided credentials are incorrect.',
]);
}
return ['token' => $user->createToken($request->device_name)->plainTextToken];
});Show plainTextToken once. Sanctum stores the hash. Revoke a lost device by deleting that token row:
$user->tokens()->where('id', $id)->delete();Readable by any script on the page, then HttpOnly cookie the browser sends:
localStorage.setItem("token", token);
api.defaults.headers.common.Authorization = `Bearer ${token}`;await api.get("/sanctum/csrf-cookie");
await api.post("/login", { email, password });Gotcha: no tokens in localStorage
Do not put that token in localStorage for the Vue app that shares your domain. Any script that can read the page can read the token. The session cookie can be HttpOnly. That is the point of the SPA flow.
A third party that must use OAuth, with its own client id, is Sanctum or Passport. The separate Vue app still needs its own URLs. That router is Vue Router on a Laravel API, and it does not belong in an Inertia app.
A test#
Cover both guards
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('returns the user from a token', function () {
Sanctum::actingAs(User::factory()->create());
$this->getJson('/api/user')->assertOk();
});actingAs covers the bearer-token guard. For the cookie login, post to /login in a feature test and follow with getJson('/api/user'). The test client keeps the session.
Private broadcast channels from this SPA need a different auth route than a normal Inertia app. The Reverb guide links to that case. Start with login. Add websockets after /api/user returns the account you expect.
Recap#
- First-party SPA = session cookie;
statefulApi()turns it on. - Stateful domains, CORS credentials and session domain must agree.
- Login and logout are web routes; fetch the CSRF cookie first.
- Tokens for other clients only; never in
localStoragefor your own SPA.
FAQ#
Should a Vue SPA use Sanctum tokens or cookies?
Cookies. Sanctum’s SPA authentication uses the session cookie for first-party apps on the same top-level domain. Tokens are for mobile apps, scripts and third parties.
Why does Sanctum return 419 on login?
The CSRF cookie was not set or not sent. Call GET /sanctum/csrf-cookie first, and check supports_credentials and SESSION_DOMAIN.
Why does /api/user return 401 after a successful login?
The SPA origin is missing from SANCTUM_STATEFUL_DOMAINS, so /api/* does not get the session.
Should the login route go in routes/api.php?
No. Put /login and /logout in routes/web.php, where the session and CSRF middleware run.
Next steps#
- Laravel and Vue CORS errors
- Sanctum or Passport for a Vue app
- Vue Router on a Laravel API
- Laravel Fortify with Vue



