Fixing Sanctum 419 & 401 Errors in Laravel + Vue: The 2026 Cross-Domain Cookie Checklist

June 01, 2026

There is a specific kind of pain that every Laravel + Vue team hits eventually: the SPA logs in perfectly on localhost, you ship it, and production immediately starts returning 419 CSRF token mismatch on login and 401 Unauthenticated on every protected request. Nothing in your code changed. The difference is entirely in cookies, domains, and headers — the parts of Sanctum that are invisible until they break.

This post is not another “how to set up Sanctum” walkthrough. We already covered the architecture and best practices for Laravel + Vue auth elsewhere. This is the debugging companion: a systematic, production-focused checklist for the cookie and CORS misconfigurations that cause 419 and 401 in real deployments — and a test matrix so you catch them in staging instead of from your users.

First, understand what’s actually happening

Sanctum’s SPA mode does not use tokens. It uses Laravel’s normal cookie-based session, layered with CSRF protection. That means three separate cookies and a header all have to line up across two origins:

  1. XSRF-TOKEN — set by GET /sanctum/csrf-cookie. Readable by JavaScript. Your HTTP client copies its (URL-decoded) value into the X-XSRF-TOKEN request header.
  2. The session cookie (e.g. laravel_session) — HttpOnly, issued at login, sent automatically by the browser on subsequent requests.
  3. The X-XSRF-TOKEN header — must match the XSRF-TOKEN cookie, or you get 419.

If the session cookie isn’t sent back, you get 401. If the CSRF token cookie/header pair doesn’t validate, you get 419. Almost every “it works locally but not in prod” bug is one of those two cookies failing to round-trip because of a domain, SameSite, Secure, or CORS mistake. The official Sanctum SPA docs describe the happy path; the rest of this post is the failure paths.

419 vs 401: read the status code first

Before changing anything, identify which error you have. They have different root causes:

  • 419 → CSRF failure. The X-XSRF-TOKEN header is missing, stale, or doesn’t match the XSRF-TOKEN cookie. This usually means the cookie wasn’t set, wasn’t readable, or wasn’t echoed back into the header.
  • 401 → Session/auth failure. Either the session cookie never arrived at login, the browser refused to send it on the next request, or the stateful-domain check didn’t recognize your SPA as first-party.

Resist the urge to “fix everything at once.” Look at the status, then walk the matching column below.

The configuration reference (get these four right)

The vast majority of 419/401 production failures come from four settings being out of sync. Here is the canonical configuration for a same-top-level-domain deployment — for example, an SPA at app.example.com talking to an API at api.example.com.

1. SANCTUM_STATEFUL_DOMAINS

This is the allowlist of front-end origins that Sanctum treats as “first-party” and therefore eligible for cookie-session auth. If your SPA’s host (including port in dev) isn’t here, Sanctum ignores the session cookie entirely and you get 401.

# .env
SANCTUM_STATEFUL_DOMAINS=app.example.com

Critical gotchas:

  • Include the port in local dev. localhost:5173 and localhost are different entries. Vite’s dev server on :5173 must be listed as localhost:5173.
  • No scheme, no trailing slash. Use app.example.com, not https://app.example.com/.
  • This is the front-end domain, not the API domain. A surprising number of bug reports list the API host here by mistake.

2. SESSION_DOMAIN

This tells Laravel which domain to scope the session and XSRF cookies to. For cross-subdomain setups, you must lead with a dot so both subdomains can read the cookie:

# .env
SESSION_DOMAIN=.example.com
// config/session.php — confirm it reads from env
'domain' => env('SESSION_DOMAIN', null),

Gotchas:

  • The leading . is what makes the cookie valid across app.example.com and api.example.com. Omit it and the cookie is locked to a single host → 401.
  • If your SPA and API are on the same host (e.g. a Vue app served by the same Laravel app under one domain), you typically leave SESSION_DOMAIN as null — forcing a value here can break things.
  • You cannot share cookies across two different registrable domains (e.g. example.com and example.net). Sanctum’s cookie mode requires a shared top-level domain. If you genuinely have two separate domains, you need API tokens, not cookie sessions.

3. CORS with credentials

Cross-origin cookie requests need three things working together, and missing any one produces a silent failure where the browser drops the cookie without an obvious error.

// config/cors.php  (publish it with: php artisan config:publish cors)
return [
    'paths' => ['api/*', 'login', 'logout', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['https://app.example.com'],
    'allowed_headers' => ['*'],
    'supports_credentials' => true, // <-- emits Access-Control-Allow-Credentials: true
];

Gotchas:

  • supports_credentials must be true. Without it the browser refuses to attach cookies to cross-origin requests → 401/419.
  • allowed_origins cannot be ['*'] when credentials are on. The CORS spec forbids Access-Control-Allow-Origin: * together with credentials. You must list explicit origins. This single rule causes an enormous share of production failures, because * works fine until you turn credentials on.
  • paths must include sanctum/csrf-cookie, login, and logout — not just api/*. If /sanctum/csrf-cookie isn’t covered, the XSRF cookie request is blocked and every later request 419s.

4. Secure & SameSite cookies

In production over HTTPS across subdomains, the cookie attributes have to permit cross-subdomain delivery.

# .env
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax

Gotchas:

  • SameSite=Lax is correct for same-top-level-domain subdomains. Requests from app.example.com to api.example.com are same-site (shared registrable domain), so Lax works and is the safer default.
  • SameSite=None is required only for true cross-site setups, and None mandates Secure=true. A cookie sent SameSite=None without Secure is silently rejected by every modern browser → 401.
  • SESSION_SECURE_COOKIE=true over plain HTTP means the cookie is never stored. If you’re testing a staging box on http://, a secure cookie will vanish and you’ll chase a phantom 401. Match the secure flag to your actual scheme.

The client side: don’t forget the browser’s half

Even with the server perfect, two client settings are mandatory. With Axios:

// resources/js/bootstrap.js (or your axios instance)
import axios from 'axios';

const http = axios.create({
  baseURL: 'https://api.example.com',
  withCredentials: true,  // send & receive cookies cross-origin
  withXSRFToken: true,    // copy XSRF-TOKEN cookie into X-XSRF-TOKEN header
});

If you use fetch instead of Axios, you must do both jobs yourself — credentials: 'include' and manually setting the header:

// fetch equivalent — easy to get subtly wrong
function xsrfToken() {
  const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
  return match ? decodeURIComponent(match[1]) : '';
}

await fetch('https://api.example.com/login', {
  method: 'POST',
  credentials: 'include',                 // <-- without this, no cookies at all
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',           // <-- Sanctum wants this for stateful detection
    'X-XSRF-TOKEN': xsrfToken(),          // <-- URL-decoded value of the cookie
  },
  body: JSON.stringify({ email, password }),
});

Client-side gotchas:

  • withXSRFToken: true is a separate flag from withCredentials. Older guides only mention withCredentials; on current Axios you need both or the header is never attached → 419.
  • The token must be URL-decoded. The cookie is URL-encoded; the header must hold the decoded value. Axios handles this; hand-rolled fetch code frequently forgets it.
  • Send Accept: application/json and an Origin/Referer header. Sanctum uses these to decide a request is from your SPA. Browsers send Origin automatically on cross-origin requests, but server-to-server or test tooling may not.

The correct end-to-end flow

For reference, the sequence the browser must complete — and where each error surfaces:

// 1. Prime CSRF — sets the XSRF-TOKEN cookie. Fails here → CORS/paths problem.
await http.get('/sanctum/csrf-cookie');

// 2. Log in — sets the session cookie. 419 here → CSRF cookie/header mismatch.
await http.post('/login', { email, password });

// 3. Authenticated request — sends both cookies. 401 here → session not round-tripping.
const { data: user } = await http.get('/api/user');

// 4. Logout — clears the session server-side.
await http.post('/logout');

A handy mental model: step 1 proves CORS + cookie delivery, step 2 proves CSRF validation, step 3 proves session persistence. When you debug, find the first step that fails and fix that — later failures are usually just downstream symptoms.

Handling expired sessions gracefully (401/419 in normal operation)

Even with everything configured correctly, sessions expire. When they do, a previously-working SPA starts getting 401 or 419 mid-session. This is expected behavior, not a bug — but it must be handled, or users see raw errors. Centralize it in an Axios response interceptor:

http.interceptors.response.use(
  (response) => response,
  (error) => {
    const status = error.response?.status;
    if (status === 401 || status === 419) {
      // Session/CSRF expired. Clear local auth state and bounce to login,
      // preserving the intended destination for a clean return.
      useAuthStore().reset();
      const target = router.currentRoute.value.fullPath;
      router.push({ name: 'login', query: { redirect: target } });
    }
    return Promise.reject(error);
  },
);

On a 419 specifically, you can attempt a single transparent recovery — re-hit /sanctum/csrf-cookie and retry the request once — before giving up and redirecting. Guard it with a retry flag so you never loop.

A staging test matrix (catch it before users do)

The reason these bugs reach production is that localhost hides them: same host, no HTTPS, lenient SameSite. Your staging environment must reproduce the production topology — real subdomains, real HTTPS, real cross-origin requests. Run this matrix in staging before every release that touches auth, infra, or domains:

Check How to verify Pass criterion
CSRF cookie issued DevTools → Network → csrf-cookie response Set-Cookie: XSRF-TOKEN=... present, scoped to .example.com
Cookie domain scope DevTools → Application → Cookies Session + XSRF cookies listed under .example.com, visible to both subdomains
CORS credentials Inspect any API response headers Access-Control-Allow-Credentials: true and an explicit Access-Control-Allow-Origin (never *)
Header round-trip Inspect an authenticated request X-XSRF-TOKEN present; value matches decoded XSRF-TOKEN cookie
Secure flag matches scheme Cookie attributes Secure set, and the site is actually served over HTTPS
Login persists Log in, hard-refresh, hit a protected route No 401 after refresh
Expiry path Manually clear the session cookie, make a request App redirects to login cleanly, no console crash
Cross-subdomain Run the flow against the real app./api. split, not localhost Full login → fetch → logout works

Wire the happy-path version of this into an end-to-end test (Playwright/Cypress hitting the staging URLs) so a regression in cookie config fails CI rather than silently shipping.

Observability: see auth failures before tickets arrive

Once it works, keep it working. A spike in 419/401 is one of the earliest signals that a deploy broke cookie or CORS config — often before any user reports it.

  • Log 419 and 401 rates as a time series, segmented by route. A sudden jump right after a deploy points squarely at a config regression.
  • Tag the cause. Distinguish “419 at /login” (CSRF/CORS misconfig) from “401 on /api/* after a period of inactivity” (normal expiry). Conflating them hides real problems.
  • Alert on the ratio, not the count. Some background 401s from idle tabs are normal; a rising percentage of authenticated requests failing is not.
  • Capture the origin header on failures. When a 419 burst happens, knowing which Origin it came from instantly tells you whether a new front-end host needs adding to SANCTUM_STATEFUL_DOMAINS.

A condensed deployment checklist

Before you ship a Laravel + Vue SPA that uses Sanctum cookie auth:

  • SANCTUM_STATEFUL_DOMAINS lists the front-end host(s), with ports in dev, no scheme/slash.
  • SESSION_DOMAIN=.example.com (leading dot) for cross-subdomain; null for same-host.
  • config/cors.php: supports_credentials => true, explicit allowed_origins (no *), and paths includes sanctum/csrf-cookie, login, logout.
  • SESSION_SECURE_COOKIE=true in production (HTTPS), and SESSION_SAME_SITE set deliberately (lax for subdomains).
  • Client has both withCredentials and withXSRFToken (or the fetch equivalents).
  • A 401/419 interceptor resets auth state and redirects to login.
  • The staging test matrix passes against real subdomains over HTTPS.
  • Auth-failure rates are logged and alerted on.

Closing

Sanctum’s SPA auth is not fragile — but it is unforgiving about the boundary between two origins. Every 419 and 401 in production traces back to a cookie that didn’t round-trip or a header that didn’t match, and every one of those is one of a small, finite set of config mistakes. Read the status code, find the first step that fails, and walk the checklist. Reproduce production topology in staging, watch your auth-failure rates after deploys, and the “works locally, breaks in prod” class of bug stops reaching your users.

Sources: Laravel Sanctum SPA Authentication docs, Laravel Sanctum CORS & Cookies configuration.


Published by Laravel & Vue.js who lives and works in Sydney building useful things.