It works in the browser and 401s during server rendering. The cause is that your SSR fetch is a different HTTP client entirely — one with no cookies, no browser Origin, and often no route to the URL you configured.
The symptom
You have a Laravel API using Sanctum’s SPA cookie authentication and a Nuxt frontend on a separate origin. In the browser everything is fine — you log in, the session cookie is set, authenticated requests succeed.
Then you fetch data during server rendering and get 401 Unauthenticated. Reload with JavaScript disabled and the page is empty. Hydrate, and the same request suddenly works.
Why it happens
Sanctum’s SPA mode is cookie and session authentication, not tokens. It works because the browser attaches the session cookie automatically to same-site requests.
During SSR there is no browser. Your $fetch runs inside the Nitro server process, which is a plain HTTP client: no cookie jar, no origin, no knowledge that a user is signed in. Laravel receives an anonymous request and answers accordingly. It is not a misconfiguration so much as a different client making the call.
Three things are missing on that server-side request, and all three matter:
- The session cookie, which the browser sent to Nuxt but Nuxt never passed on.
- An Origin header Sanctum recognises as a stateful domain.
- Often, a reachable URL — the address the browser uses may not resolve from inside your server.
Forward the cookie during SSR
Nuxt gives you the incoming request’s headers via useRequestHeaders. The fix is to pass the cookie through on server-side calls only:
// Browser requests carry cookies themselves. Server-side ones carry nothing,
// so the two cookies Sanctum needs have to be forwarded by hand.
function sessionCookies(sessionCookieName) {
const raw = useRequestHeaders(['cookie']).cookie
if (!raw) return undefined
const wanted = new Set([sessionCookieName, 'XSRF-TOKEN'])
const kept = raw.split(';').map((p) => p.trim())
.filter((p) => wanted.has(p.slice(0, p.indexOf('=')).trim()))
return kept.length ? kept.join('; ') : undefined
}
let forwarded = {}
if (import.meta.server) {
const cookie = sessionCookies(config.public.sessionCookie)
forwarded = {
origin: config.public.appUrl || useRequestURL().origin,
...(cookie ? { cookie } : {}),
}
}
return $fetch(request, { baseURL, credentials: 'include',
headers: { Accept: 'application/json', ...forwarded } })
Enter fullscreen mode Exit fullscreen mode
The origin matters as much as the cookie. Sanctum decides whether a request is “stateful” by comparing the referer or origin against SANCTUM_STATEFUL_DOMAINS. A server-to-server call has no Origin at all, so even with a valid session cookie Sanctum treats it as a token request and ignores the session.
Two details worth getting right
Forward two cookies, not the whole header. Sanctum requires the frontend and API to share a parent domain, which means the incoming Cookie header can carry cookies set by sibling subdomains and third-party scripts. Passing it through wholesale hands all of that to your API on every server-rendered request. Two cookies are needed; the rest is other people’s credentials travelling for no reason.
Name your public URL rather than deriving it. useRequestURL() reads the incoming request, so behind Nginx or Cloudflare it depends on X-Forwarded-Host and X-Forwarded-Proto being set and trusted — and resolves to an internal hostname when they are not. Sanctum then stops seeing the request as stateful and every SSR fetch 401s in production only, because local development has no proxy in front of it. Configure the public URL and keep the request origin as the development fallback.
One trap in that first point: Laravel names its session cookie after APP_NAME — Str::slug(APP_NAME).'-session' — so it is rarely the framework default. Filter for the wrong name and you drop the real session cookie, producing exactly the 401 this article is about. Worth a development-time warning when a *-session cookie shows up that you weren’t expecting.
The second trap: the server can’t reach your API URL
This one costs people an extra evening, because the cookie fix looks correct and the request still fails — now with a connection error rather than a 401.
Your public API base might be http://localhost:8000 in development or https://api.example.com in production. Inside a container, localhost is that container, not your API. And a server sitting next to the API has no reason to route back out through the public internet to reach it.
So the base URL has to differ by environment:
// Server-side calls use an internal address; the browser uses the public one.
const baseURL = import.meta.server
? config.apiBaseServer // e.g. http://app:8000 on the Docker network
: config.public.apiBase // e.g. https://api.example.com
Enter fullscreen mode Exit fullscreen mode
In Nuxt’s runtimeConfig, anything outside public is server-only — which is exactly the distinction you want here.
CSRF, which is a separate problem
Sanctum’s SPA mode also enforces CSRF on state-changing requests. The flow is: call /sanctum/csrf-cookie once, Laravel sets an XSRF-TOKEN cookie, and every subsequent POST, PUT or DELETE echoes it back in an X-XSRF-TOKEN header.
const xsrf = useCookie('XSRF-TOKEN').value
headers: {
// URL-decoded: Laravel writes it encoded, and comparing the raw
// value against the decoded session token fails every time.
...(xsrf ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrf) } : {}),
}
Enter fullscreen mode Exit fullscreen mode
That decodeURIComponent is the detail behind a great many “Sanctum 419 Page Expired” reports. The cookie is URL-encoded; the comparison is not.
The Laravel side
Four pieces of configuration have to agree. Any one of them wrong produces the same 401.
1. Stateful middleware
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})
Enter fullscreen mode Exit fullscreen mode
2. Stateful domains
SANCTUM_STATEFUL_DOMAINS lists the frontend origins allowed to authenticate by session. Include the port in development — localhost:3000 and localhost are different entries.
3. CORS with credentials
// config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie', 'broadcasting/auth'],
'allowed_origins' => explode(',', env('FRONTEND_URLS', 'http://localhost:3000')),
'supports_credentials' => true,
Enter fullscreen mode Exit fullscreen mode
supports_credentials must be true or the browser discards the cookie. sanctum/csrf-cookie must be in paths or the CSRF request itself fails CORS. And allowed_origins cannot be * when credentials are enabled — the browser rejects that combination outright.
4. Session cookie domain
This is the one that bites in production. A cookie set on api.example.com is not sent to app.example.com unless SESSION_DOMAIN is the shared parent:
SESSION_DOMAIN=.example.com
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
Enter fullscreen mode Exit fullscreen mode
Which carries a real architectural constraint: Sanctum’s SPA mode requires both applications to share a parent domain. A frontend on myapp.vercel.app talking to an API on api.example.com cannot use session cookies at all — no configuration fixes it, because there is no shared domain for the cookie to live on. Put the frontend on a subdomain of the same registrable domain, or use tokens instead.
Checklist
Symptom Usual cause 401 during SSR, fine in the browser Cookie header not forwarded 401 with the cookie forwarded No Origin header, so not treated as stateful Connection refused during SSR only Server using the browser-facing base URL 419 on POSTXSRF-TOKEN not URL-decoded
Cookie never set at all
supports_credentials false, or wildcard origin
Works locally, fails in production
SESSION_DOMAIN not the shared parent, or an Origin derived from an untrusted proxy header
401 only after adding a cookie filter
Session cookie name is not the framework default — it follows APP_NAME
This is the configuration Nuxavel ships with, tested end to end — a Laravel 13 API and a Nuxt 4 SSR frontend, with the cookie forwarding above in a single useApi composable. If you would rather see it working than read about it, the live demo is a real deployment with published sign-in details.
Either way, the code above is the whole fix. It is not hidden behind anything.
Updated after @sebasflores pointed out two real weaknesses in the original
version — the cookie filtering and the Origin handling are both his.
답글 남기기