[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-laravel-sanctum-401-after-login-stateful-domains":3},{"content":4,"lastModified":5},"\u003Cp>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 \u003Ccode>401 Unauthenticated\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F17603682\u002Fpexels-photo-17603682.jpeg?auto=compress&amp;cs=tinysrgb&amp;w=1200\" alt=\"A closed wooden gate set into a stone wall, with a small round latch at the top and greenery hanging over it\" loading=\"lazy\" \u002F>\n\n\u003Ch2>One header and one pattern decide whether you get a session\u003C\u002Fh2>\n\n\u003Cp>Before any session middleware is added to an API route, \u003Ccode>EnsureFrontendRequestsAreStateful\u003C\u002Fcode> asks one question: did this request come from my own frontend? It reads \u003Ccode>Referer\u003C\u002Fcode> first, falls back to \u003Ccode>Origin\u003C\u002Fcode>, and if neither is present it answers no and stops.\u003C\u002Fp>\n\n\u003Cp>Otherwise it strips one leading scheme, appends a trailing slash, and matches what is left against every entry in \u003Ccode>config('sanctum.stateful')\u003C\u002Fcode>, each entry rebuilt as a wildcard pattern.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">$domain = $request-&gt;headers-&gt;get('referer') ?: $request-&gt;headers-&gt;get('origin');\n\nif (is_null($domain)) {\n    return false;\n}\n\n$domain = Str::replaceFirst('https:\u002F\u002F', '', $domain);\n$domain = Str::replaceFirst('http:\u002F\u002F', '', $domain);\n$domain = Str::endsWith($domain, '\u002F') ? $domain : \"{$domain}\u002F\";\n\nreturn Str::is(Collection::make(config('sanctum.stateful'))-&gt;map(function ($uri) use ($request) {\n    $uri = $uri === Sanctum::$currentRequestHostPlaceholder\n        ? $request-&gt;getHttpHost()\n        : $uri;\n\n    return trim($uri).'\u002F*';\n})-&gt;all(), $domain);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ccode>web\u003C\u002Fcode> guard, finds nothing to read because no session was started, and falls through to the \u003Ccode>Authorization\u003C\u002Fcode> header, where there is no bearer token either. The route middleware turns that into a 401.\u003C\u002Fp>\n\n\u003Cp>It also explains why moving the route to \u003Ccode>routes\u002Fweb.php\u003C\u002Fcode> fixes it: that group starts a session on every request.\u003C\u002Fp>\n\n\u003Ch2>The four ways that string fails to match\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>.env\u003C\u002Fcode>. Write \u003Ccode>https:\u002F\u002Fapp.staging.example.com\u003C\u002Fcode> and the pattern becomes \u003Ccode>https:\u002F\u002Fapp.staging.example.com\u002F*\u003C\u002Fcode>, while the string being compared is \u003Ccode>app.staging.example.com\u002F\u003C\u002Fcode>. Nothing matches, and nothing tells you.\u003C\u002Fp>\n\n\u003Cp>A trailing slash fails one character later, because \u003Ccode>\u002F*\u003C\u002Fcode> gets appended whether or not the entry ends in one. \u003Ccode>app.staging.example.com\u002F\u003C\u002Fcode> becomes \u003Ccode>app.staging.example.com\u002F\u002F*\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>The port is what bites in production, for a boring reason: the matcher has no idea what a port is. \u003Ccode>app.example.com\u003C\u002Fcode> covers 443 and nothing else, so staging on \u003Ccode>:8443\u003C\u002Fcode> needs its own entry. The same rule makes the bare \u003Ccode>localhost\u003C\u002Fcode> in Sanctum's default list nearly useless. I ran Laravel's matcher against those defaults and \u003Ccode>http:\u002F\u002Flocalhost:5173\u003C\u002Fcode>, Vite's default port, does not match. Only the explicit \u003Ccode>localhost:3000\u003C\u002Fcode> entry saves the usual Nuxt setup.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-text\">SANCTUM_STATEFUL_DOMAINS=https:\u002F\u002Fapp.staging.example.com   not stateful\nSANCTUM_STATEFUL_DOMAINS=app.staging.example.com\u002F          not stateful\nSANCTUM_STATEFUL_DOMAINS=app.staging.example.com\u002Fapp       not stateful\nSANCTUM_STATEFUL_DOMAINS=app.staging.example.com           stateful\nSANCTUM_STATEFUL_DOMAINS=*.example.com                     stateful\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>All five were checked against the real matcher. The fourth is the fix.\u003C\u002Fp>\n\n\u003Ch2>A debug route beats staring at the network panel\u003C\u002Fh2>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">Route::get('\u002Fdebug\u002Fstateful', function (Request $request) {\n    return [\n        'stateful' =&gt; EnsureFrontendRequestsAreStateful::fromFrontend($request),\n        'referer' =&gt; $request-&gt;headers-&gt;get('referer'),\n        'origin' =&gt; $request-&gt;headers-&gt;get('origin'),\n        'config' =&gt; config('sanctum.stateful'),\n    ];\n});\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>The 419 on the other side of the gate\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>_token\u003C\u002Fcode> input, then \u003Ccode>X-CSRF-TOKEN\u003C\u002Fcode> exactly as it arrives, then \u003Ccode>X-XSRF-TOKEN\u003C\u002Fcode>, decrypted with \u003Ccode>APP_KEY\u003C\u002Fcode> and stripped of its cookie prefix first.\u003C\u002Fp>\n\n\u003Cp>That asymmetry is the trap. The \u003Ccode>XSRF-TOKEN\u003C\u002Fcode> cookie holds an encrypted value, so it belongs in \u003Ccode>X-XSRF-TOKEN\u003C\u002Fcode>. \u003Ccode>X-CSRF-TOKEN\u003C\u002Fcode> 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 \u003Ccode>APP_KEY\u003C\u002Fcode> cannot be decrypted, so you get the same generic message.\u003C\u002Fp>\n\n\u003Cp>One thing worth carrying into that session: in Laravel 13 the middleware that used to be \u003Ccode>VerifyCsrfToken\u003C\u002Fcode> is now \u003Ccode>PreventRequestForgery\u003C\u002Fcode>, and it reads \u003Ccode>Sec-Fetch-Site\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>Sanctum also pins \u003Ccode>session.http_only\u003C\u002Fcode> to true and \u003Ccode>session.same_site\u003C\u002Fcode> to \u003Ccode>lax\u003C\u002Fcode> on every request that passes through it, whatever \u003Ccode>.env\u003C\u002Fcode> says. That is the mechanical reason the docs insist your SPA and API share a top level domain.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cp>Same family as the \u003Ca href=\"\u002Fposts\u002Flaravel-n-plus-one-queries-find-fix\u002F\">N+1 queries\u003C\u002Fa> 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 \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> now, next to the \u003Ca href=\"\u002Fposts\u002Fnuxt-runtimeconfig-env-vars-undefined-production\u002F\">stale config cache\u003C\u002Fa> trick from the same project, because both come down to a value that was not what I assumed.\u003C\u002Fp>\n","2026-10-08",1791775771997]