DEV Community

Cover image for Laravel Sanctum with Nuxt SSR: why the session disappears on the server
Nuxavel
Nuxavel

Posted on • Originally published at nuxavel.com

Laravel Sanctum with Nuxt SSR: why the session disappears on the server

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:

  1. The session cookie, which the browser sent to Nuxt but Nuxt never passed on.
  2. An Origin header Sanctum recognises as a stateful domain.
  3. 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 incoming request's Cookie header has to be forwarded by hand.
const forwarded = import.meta.server
  ? { ...useRequestHeaders(['cookie']), origin: useRequestURL().origin }
  : {}

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. Setting it to useRequestURL().origin — the public URL of your own frontend — is what makes the check pass.

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 POST XSRF-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

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.

Top comments (0)