Laravel Sanctum SPA Auth Across Subdomains Will Bite You
Sanctum's subdomain cookie auth looks simple until CORS and session config conspire to ruin your day. Here's the config that actually works.
Sanctum's documentation makes subdomain SPA authentication look like a ten-minute job. It is not a ten-minute job. I've set this up across probably a dozen projects now — e-commerce frontends on app.domain.com hitting APIs on api.domain.com, healthcare portals, a real estate dashboard — and every single time there's a moment where the cookie just isn't being sent and you're staring at a 401 wondering what Laravel thinks it's doing.
This is the post I wish existed the first time I hit it.
What Sanctum's Cookie Auth Actually Does
Sanctum gives you two authentication modes: API tokens (stateless, like a Bearer token) and cookie-based session auth for first-party SPAs. The cookie mode is the one you want when your frontend and backend share a domain. It's more secure than storing tokens in localStorage, CSRF protection comes along for the ride, and you're not rolling your own token refresh logic.
The way it works: your SPA hits /sanctum/csrf-cookie to get a session cookie and a CSRF token, then logs in via a normal POST. From that point on, every request carries the session cookie and the X-XSRF-TOKEN header, and Sanctum validates both. Laravel's session machinery does the heavy lifting — Sanctum is mostly just middleware wiring.
That's fine when everything lives on one origin. The moment your frontend is on app.example.com and your API is on api.example.com, you're coordinating three separate systems that all need to agree on domain scope: Sanctum itself, Laravel's CORS package, and the session config. They don't fail loudly when they disagree. They just silently refuse to send or accept cookies.
The Config That Actually Works
Let me show you the full picture, because every tutorial shows one piece and omits two others.
config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
In your .env:
SANCTUM_STATEFUL_DOMAINS=app.example.com,api.example.com
Both domains. I've seen teams only put the frontend domain here. The API domain needs to be listed too, or requests originating from it won't be treated as stateful.
config/session.php
'domain' => env('SESSION_DOMAIN', null),
In your .env:
SESSION_DOMAIN=.example.com
The leading dot is not optional. Without it, the cookie is scoped to the exact hostname that set it. With the dot, it's scoped to example.com and all subdomains. This is the single most common thing I see missing when I'm debugging someone else's broken setup.
Also make sure your session driver is something that persists across requests — database or redis, not array. Obvious in production, easy to miss when you're testing locally with a config someone else set up.
SESSION_DRIVER=redis
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=none
SameSite=None is required for cross-subdomain cookies in modern browsers. And SameSite=None requires Secure=true. If you're testing over plain HTTP locally, this will not work — you need HTTPS or a local proxy. I use Valet with its built-in TLS for local subdomain work. It saves a lot of pain.
config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'https://app.example.com')],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
supports_credentials must be true. This tells the CORS middleware to send Access-Control-Allow-Credentials: true in responses. Without it, the browser won't expose the response to your frontend JavaScript, even if the request succeeds on the server.
And allowed_origins must be an explicit origin — not ['*']. Browsers reject credentialed requests when the server responds with a wildcard origin. You'll get a CORS error in the console and a 401 in your app, and they look completely unrelated until you know what you're looking for.
The Gotchas That Bit Me
The CSRF cookie request has to come from the right origin. Your SPA needs to call https://api.example.com/sanctum/csrf-cookie before the login request. Axios does this automatically if you configure it:
axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'https://api.example.com';
// Before login:
await axios.get('/sanctum/csrf-cookie');
await axios.post('/login', { email, password });
If you skip the CSRF cookie request, or if withCredentials isn't set, the session cookie won't be sent and every subsequent request fails as unauthenticated.
The middleware group matters. Sanctum's stateful middleware has to be on your routes. In app/Http/Kernel.php, make sure 'api' middleware group includes it:
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
In Laravel 11, this moved into the application's bootstrap, but the principle is the same — verify it's actually applied to your API routes.
Vite dev server proxying will mask your CORS config. I worked with a team that had their Vite config proxying /api/* to the backend, so everything looked like same-origin locally. They deployed, and nothing worked. Their CORS and Sanctum config had never been exercised. Test your frontend against the actual API origin early, not through a proxy.
Cookie domain mismatch in production vs. staging. If staging is on app.staging.example.com and your SESSION_DOMAIN is .example.com, the cookie will be sent across staging and production subdomains. Scoping it to .staging.example.com in your staging .env avoids leaking session state between environments. This bit me on a healthcare project where we had staging and production running on the same root domain. Not a great day.
The Referer header. Sanctum's stateful middleware checks the Referer or Origin header against SANCTUM_STATEFUL_DOMAINS. If your frontend is making requests without a Referer (some privacy-focused browsers strip it), or if your CDN is stripping headers, requests silently fall back to token auth — which succeeds with no token as a 401 rather than erroring out in a way that tells you why.
Debugging It When It's Broken
When cookies aren't being set or sent, the browser DevTools Network tab is your first stop. Look at:
- The response to
/sanctum/csrf-cookie— is there aSet-Cookieheader? Does the domain on the cookie match your frontend's domain? - The login request — is
Cookiebeing sent with it? - Any API request — is
X-XSRF-TOKENpresent?
If the cookie is set but not being sent, it's almost always SameSite, Secure, or domain scope. If the cookie isn't being set at all, check your session driver, SESSION_DOMAIN, and whether the CSRF endpoint is even in the CORS paths array.
On the server side, php artisan tinker with request()->session() doesn't help much here because the session won't exist in tinker context. What does help: temporarily logging EnsureFrontendRequestsAreStateful::class to see whether it's treating a request as stateful or not. It's not elegant, but it's faster than guessing.
When I'd Reach for This (and When I Wouldn't)
Sanctum cookie auth is the right call when your SPA and API share a parent domain and you control both. It's genuinely more secure than token-in-localStorage, and once the config is right it just works — login, logout, session expiry, remember-me, all handled by Laravel's session layer.
I wouldn't use it when the frontend is on a completely different domain (no shared parent), or when you're building a mobile app or a third-party integration. That's what API tokens are for. Sanctum handles both, which is nice — you don't need Passport for straightforward token issuance.
I also wouldn't use it if your infrastructure team can't guarantee HTTPS everywhere including local development. The SameSite=None; Secure requirement is a hard constraint, and working around it (by disabling secure cookies locally) creates a config drift that eventually causes a production incident.
One Last Thing
The setup I've described above works. I've run it on Laravel 9, 10, and 11 across multiple client projects. The individual pieces are all documented — they're just documented in three different places, and the interaction between them isn't spelled out anywhere official.
Get the session domain dot, the CORS credentials flag, and both domains in the stateful list right, and Sanctum cookie auth is solid. Miss any one of them and you'll spend an afternoon staring at 401s that don't tell you why.
Need help shipping something like this? Get in touch.