Authenticate a Vue SPA with Laravel Sanctum

Cookie sessions for a first-party Vue app, CSRF, stateful domains, and the token path that is only for a phone or a third party.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-sanctum-vue-spa.md
Log the SPA in with the session cookie, then read the current user.
Do not call axios.get("/api/user"). That request does not send the session cookie.
Do not treat a missing SANCTUM_STATEFUL_DOMAINS as a 419. That case is 401.
A 419 is CSRF: check SESSION_DOMAIN and supports_credentials.
Show the diff. Do not commit.

Next: Vue Router on a Laravel API

On this page · 10 sections
  1. Install the API
  2. Domains, cookies, and CORS
  3. The login route is a web route
  4. The Vue side
  5. Tokens, only for the other clients
  6. A test
  7. Recap
  8. FAQ
  9. Next steps
  10. Sources

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 on supports_credentials.
  • Login is a web route. Call GET /sanctum/csrf-cookie before the first POST.
  • Axios: withCredentials + withXSRFToken. 419 = CSRF, 401 = no session.
  • Bearer tokens are for phones, scripts and third parties, not your own SPA.

What you'll learn

  • How to enable Sanctum SPA (cookie) authentication
  • The domain, cookie and CORS settings that must agree
  • How to log in from Axios, and when a bearer token is right

Who it's for: Developers building a Vue SPA on a Laravel API

Applies to: Laravel 13 · Sanctum · Vue 3 · Axios · Pest

“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.

  1. Install the API and turn on stateful requests.
  2. Line up domains, cookies and CORS.
  3. Put login on a web route.
  4. Configure Axios and fetch the CSRF cookie first.
  5. Test it.

Install the API#

bash
php artisan install:api

That command installs Sanctum and publishes the API routes. Then allow cookie authentication from your own front end:

bootstrap/app.phpphp
->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:

bash
php artisan config:publish cors
config/cors.phpphp
'supports_credentials' => true,

For two real subdomains, set the session cookie on the parent:

config/session.phpphp
'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.

routes/web.phpphp
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:

routes/api.phpphp
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:

bash
npm install axios
typescript
// 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.

typescript
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;
}
StatusMeaningFix
419CSRF cookie missing or not storedCall /sanctum/csrf-cookie first; check SESSION_DOMAIN, supports_credentials
401Request arrived without a sessionCheck SANCTUM_STATEFUL_DOMAINS, then log in again
CORS errorPreflight blockedCORS 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.

app/Models/User.phpphp
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}
routes/api.phpphp
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:

php
$user->tokens()->where('id', $id)->delete();

Readable by any script on the page, then HttpOnly cookie the browser sends:

src/api.tstypescript
localStorage.setItem("token", token);
api.defaults.headers.common.Authorization = `Bearer ${token}`;
src/api.tstypescript
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

tests/Feature/ApiUserTest.phpphp
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 localStorage for 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#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.