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:
XSRF-TOKEN— set byGET /sanctum/csrf-cookie. Readable by JavaScript. Your HTTP client copies its (URL-decoded) value into theX-XSRF-TOKENrequest header.- The session cookie (e.g.
laravel_session) —HttpOnly, issued at login, sent automatically by the browser on subsequent requests. - The
X-XSRF-TOKENheader — must match theXSRF-TOKENcookie, 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-TOKENheader is missing, stale, or doesn’t match theXSRF-TOKENcookie. 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.comCritical gotchas:
- Include the port in local dev.
localhost:5173andlocalhostare different entries. Vite’s dev server on:5173must be listed aslocalhost:5173. - No scheme, no trailing slash. Use
app.example.com, nothttps://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 acrossapp.example.comandapi.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_DOMAINasnull— forcing a value here can break things. - You cannot share cookies across two different registrable domains (e.g.
example.comandexample.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_credentialsmust betrue. Without it the browser refuses to attach cookies to cross-origin requests → 401/419.allowed_originscannot be['*']when credentials are on. The CORS spec forbidsAccess-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.pathsmust includesanctum/csrf-cookie,login, andlogout— not justapi/*. If/sanctum/csrf-cookieisn’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=laxGotchas:
SameSite=Laxis correct for same-top-level-domain subdomains. Requests fromapp.example.comtoapi.example.comare same-site (shared registrable domain), soLaxworks and is the safer default.SameSite=Noneis required only for true cross-site setups, andNonemandatesSecure=true. A cookie sentSameSite=NonewithoutSecureis silently rejected by every modern browser → 401.SESSION_SECURE_COOKIE=trueover plain HTTP means the cookie is never stored. If you’re testing a staging box onhttp://, 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: trueis a separate flag fromwithCredentials. Older guides only mentionwithCredentials; 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
fetchcode frequently forgets it. - Send
Accept: application/jsonand anOrigin/Refererheader. Sanctum uses these to decide a request is from your SPA. Browsers sendOriginautomatically 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
Originit came from instantly tells you whether a new front-end host needs adding toSANCTUM_STATEFUL_DOMAINS.
A condensed deployment checklist
Before you ship a Laravel + Vue SPA that uses Sanctum cookie auth:
-
SANCTUM_STATEFUL_DOMAINSlists the front-end host(s), with ports in dev, no scheme/slash. -
SESSION_DOMAIN=.example.com(leading dot) for cross-subdomain;nullfor same-host. -
config/cors.php:supports_credentials => true, explicitallowed_origins(no*), andpathsincludessanctum/csrf-cookie,login,logout. -
SESSION_SECURE_COOKIE=truein production (HTTPS), andSESSION_SAME_SITEset deliberately (laxfor subdomains). - Client has both
withCredentialsandwithXSRFToken(or thefetchequivalents). - 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.