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

- URL: https://laravel-vuejs.space/laravel-sanctum-vue-spa
- Author: Anass Ez-zouaine (https://laravel-vuejs.space/author/anass-ez-zouaine)
- Published: 2026-10-10
- Topics: Laravel, VueJs, Sanctum

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](https://laravel-vuejs.space/laravel-vue-in-2026) 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.php`
```php
->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.php`
```php
'supports_credentials' => true,
```

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

`config/session.php`
```php
'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.php`
```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:

`routes/api.php`
```php
Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');
```

[Laravel Fortify](https://laravel.com/docs/13.x/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
```

```ts
// 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.

```ts
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](https://laravel-vuejs.space/laravel-vue-cors) |

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.php`
```php
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}
```

`routes/api.php`
```php
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:**

`src/api.ts`
```ts
localStorage.setItem("token", token);
api.defaults.headers.common.Authorization = `Bearer ${token}`;
```

**HttpOnly cookie the browser sends:**

`src/api.ts`
```ts
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](https://laravel-vuejs.space/laravel-sanctum-vs-passport). The separate Vue app still needs its own URLs. That router is [Vue Router on a Laravel API](https://laravel-vuejs.space/vue-router-laravel-spa), and it does not belong in an Inertia app.

## A test

> **Cover both guards**
>
> `tests/Feature/ApiUserTest.php`
> ```php
> 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](https://laravel-vuejs.space/laravel-reverb-vue) 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

**Q: 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.

**Q: 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`.

**Q: 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.

**Q: 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](https://laravel-vuejs.space/laravel-vue-cors)
- [Sanctum or Passport for a Vue app](https://laravel-vuejs.space/laravel-sanctum-vs-passport)
- [Vue Router on a Laravel API](https://laravel-vuejs.space/vue-router-laravel-spa)
- [Laravel Fortify with Vue](https://laravel-vuejs.space/laravel-fortify-vue)

## Sources

- [Laravel 13: Sanctum](https://laravel.com/docs/13.x/sanctum)
- [Laravel 13: Sanctum, SPA authentication](https://laravel.com/docs/13.x/sanctum#spa-authentication)
- [Axios: Request config](https://axios-http.com/docs/req_config)
