Laravel Sanctum 401 After Login: The String That Decides It
A Laravel API and a Nuxt SPA, both of them mine, both working on Friday. The login request returns 200, the session cookie lands in the browser, and the next request to a Sanctum route comes back 401 Unauthenticated.
Not a 419. A 419 at least names the token as the problem. A 401 on the request after a successful login sends you back to the login form to check your password.
The password was fine. The cookie was being sent. The session row on the server had the user id in it. What had gone missing was the decision to treat the request as mine at all, and that decision is one string comparison inside Sanctum.
One header and one pattern decide whether you get a session
Before any session middleware is added to an API route, EnsureFrontendRequestsAreStateful asks one question: did this request come from my own frontend? It reads Referer first, falls back to Origin, and if neither is present it answers no and stops.
Otherwise it strips one leading scheme, appends a trailing slash, and matches what is left against every entry in config('sanctum.stateful'), each entry rebuilt as a wildcard pattern.
$domain = $request->headers->get('referer') ?: $request->headers->get('origin');
if (is_null($domain)) {
return false;
}
$domain = Str::replaceFirst('https://', '', $domain);
$domain = Str::replaceFirst('http://', '', $domain);
$domain = Str::endsWith($domain, '/') ? $domain : "{$domain}/";
return Str::is(Collection::make(config('sanctum.stateful'))->map(function ($uri) use ($request) {
$uri = $uri === Sanctum::$currentRequestHostPlaceholder
? $request->getHttpHost()
: $uri;
return trim($uri).'/*';
})->all(), $domain);
When that returns false, session handling is never bolted onto the api group. Sanctum's guard then behaves as it does for any third party: it consults the web guard, finds nothing to read because no session was started, and falls through to the Authorization header, where there is no bearer token either. The route middleware turns that into a 401.
It also explains why moving the route to routes/web.php fixes it: that group starts a session on every request.
The four ways that string fails to match
The docs tell you to use a host without a scheme and to include the port. They do not say why, and the why is the part that sticks. The middleware strips the scheme from the header it received, not from the value in your .env. Write https://app.staging.example.com and the pattern becomes https://app.staging.example.com/*, while the string being compared is app.staging.example.com/. Nothing matches, and nothing tells you.
A trailing slash fails one character later, because /* gets appended whether or not the entry ends in one. app.staging.example.com/ becomes app.staging.example.com//*, a pattern that wants two slashes in a value that only has one. Paste a path from the address bar and the pattern narrows to that subtree instead.
The port is what bites in production, for a boring reason: the matcher has no idea what a port is. app.example.com covers 443 and nothing else, so staging on :8443 needs its own entry. The same rule makes the bare localhost in Sanctum's default list nearly useless. I ran Laravel's matcher against those defaults and http://localhost:5173, Vite's default port, does not match. Only the explicit localhost:3000 entry saves the usual Nuxt setup.
SANCTUM_STATEFUL_DOMAINS=https://app.staging.example.com not stateful
SANCTUM_STATEFUL_DOMAINS=app.staging.example.com/ not stateful
SANCTUM_STATEFUL_DOMAINS=app.staging.example.com/app not stateful
SANCTUM_STATEFUL_DOMAINS=app.staging.example.com stateful
SANCTUM_STATEFUL_DOMAINS=*.example.com stateful
All five were checked against the real matcher. The fourth is the fix.
A debug route beats staring at the network panel
Every guide on this error sends you to DevTools to check whether the cookies came back. Mine did, on every request, which is why that panel cost me an hour. So I asked the decision itself instead.
Route::get('/debug/stateful', function (Request $request) {
return [
'stateful' => EnsureFrontendRequestsAreStateful::fromFrontend($request),
'referer' => $request->headers->get('referer'),
'origin' => $request->headers->get('origin'),
'config' => config('sanctum.stateful'),
];
});
Loaded from the SPA it answers true. Handed to curl, which sends neither header, it answers false with two nulls beside it. The config key prints next to the verdict so you can spot the bad entry.
With the verdict true, the api group gains the stack it was missing: encrypted cookies, a started session, the request forgery middleware, the authenticated session middleware, and a tag marking the request as Sanctum's. A lot of machinery to leave switched off.
The 419 on the other side of the gate
A 419 means the CSRF token did not match, and the header you pick decides whether it can. The token is read in a fixed order: a _token input, then X-CSRF-TOKEN exactly as it arrives, then X-XSRF-TOKEN, decrypted with APP_KEY and stripped of its cookie prefix first.
That asymmetry is the trap. The XSRF-TOKEN cookie holds an encrypted value, so it belongs in X-XSRF-TOKEN. X-CSRF-TOKEN expects the raw token from your page's meta tag. Put the cookie value in the raw header and you have manufactured a 419 that no cache clear will move. The reverse fails quietly too: a cookie encrypted under an old APP_KEY cannot be decrypted, so you get the same generic message.
One thing worth carrying into that session: in Laravel 13 the middleware that used to be VerifyCsrfToken is now PreventRequestForgery, and it reads Sec-Fetch-Site before it looks at any token, letting a same-origin browser request through without one. That header only arrives over HTTPS, so local and staging can disagree about identical code.
Sanctum also pins session.http_only to true and session.same_site to lax on every request that passes through it, whatever .env says. That is the mechanical reason the docs insist your SPA and API share a top level domain.
None of this is hidden. It is all in code you already have installed. Before I touch CORS or Axios, I print one thing: what the stateful check answered, and the two headers it answered from.
Same family as the N+1 queries that looked like slow infrastructure: the error names the wrong layer, and the layer that decided is one step earlier in the request. The debug route lives in Snippet Ark now, next to the stale config cache trick from the same project, because both come down to a value that was not what I assumed.