With Laravel Sanctum SPA authentication, what do statefulApi(), SANCTUM_STATEFUL_DOMAINS and the /sanctum/csrf-cookie route each contribute to a logged-in API request?
answer
- session middleware on API routes
- EnsureFrontendRequestsAreStateful in the api group
- Referer or Origin matched, port included
- 204 plus an XSRF-TOKEN cookie
- X-XSRF-TOKEN header echoed back
basics
~10 sstatefulApi() adds EnsureFrontendRequestsAreStateful to the api group; SANCTUM_STATEFUL_DOMAINS lists the hosts whose Referer or Origin make a request stateful, gaining cookies, session and CSRF; /sanctum/csrf-cookie returns 204 and sets XSRF-TOKEN before login.
solid answer
~40 sIn Laravel 13, `$middleware->statefulApi()` in `bootstrap/app.php` puts Sanctum's `EnsureFrontendRequestsAreStateful` at the front of the `api` middleware group. That middleware takes the request's `Referer` (or, if absent, `Origin`) host **including the port** and matches it against `config('sanctum.stateful')`, built from the comma-separated `SANCTUM_STATEFUL_DOMAINS` variable. On a match it runs cookie encryption, `StartSession`, the CSRF check and Sanctum's `AuthenticateSession` for that API request, so the `web` session can authenticate it; with no match the request stays stateless and only a Bearer token works. Before logging in, the SPA calls `GET /sanctum/csrf-cookie`, a `web`-group route that answers 204 so the CSRF middleware sets the `XSRF-TOKEN` cookie; the HTTP client returns it as `X-XSRF-TOKEN` on the login POST and every later write.
code
php · 14 lines<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
->create();go deeper
Remember the order: enable statefulApi(), list the SPA's host in SANCTUM_STATEFUL_DOMAINS, call /sanctum/csrf-cookie, then post to the login route.
Explain how EnsureFrontendRequestsAreStateful matches Referer or Origin against the stateful list, port included, and which middleware it then runs.
Diagnose 401 versus 419 from an SPA quickly: a missing stateful entry, a missing XSRF cookie, or a cross-subdomain cookie and CORS mismatch.
Weigh keeping the SPA and API on one registrable domain against the cost of token-based auth when they cannot share cookies.
## The problem the three pieces solve Laravel's `api` middleware group is **stateless**: it does not decrypt cookies, start a session or check CSRF. A first-party SPA, however, wants to call `/api/...` routes as a user who logged in through the ordinary `web` session. Sanctum's SPA mode bolts the session stack onto API requests **only when they come from your own frontend**. Three pieces make that work. ## 1. `statefulApi()` — switching the behaviour on In the Laravel 13 skeleton there is no `app/Http/Kernel.php`; middleware is configured in `bootstrap/app.php`: - `->withMiddleware(fn (Middleware $middleware) => $middleware->statefulApi())` sets a flag on the builder. - With the flag set, the framework prepends `Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful` to the `api` group, ahead of throttling and route-model binding. - Without it, API routes never see a session, and `auth:sanctum` can authenticate only Bearer tokens. ## 2. `SANCTUM_STATEFUL_DOMAINS` — deciding which requests are "frontend" `config/sanctum.php` builds `stateful` by splitting `SANCTUM_STATEFUL_DOMAINS` on commas. When the variable is unset, the default is `localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1` plus the host and port of `APP_URL`. For each request the middleware: 1. takes the `Referer` header, falling back to `Origin`; with neither, the request is not stateful; 2. strips `http://` or `https://` and ensures a trailing slash; 3. matches the result against each stateful entry as the pattern `<entry>/*`. Because the pattern is literal, **the port is part of the match**: `localhost` does not cover `localhost:5173`. On a match the middleware runs, for that request only, cookie encryption, queued cookies, `StartSession`, the CSRF middleware named in `sanctum.middleware.validate_csrf_token` and Sanctum's `AuthenticateSession`. On every API request it handles, stateful or not, it also forces `session.http_only` to `true` and `session.same_site` to `lax`. ## 3. `/sanctum/csrf-cookie` — priming CSRF before login Sanctum's service provider registers `GET /sanctum/csrf-cookie` in the `web` group, named `sanctum.csrf-cookie`. Its controller does nothing but answer **204 No Content**; the useful side effect is that the `web` group's CSRF middleware attaches an `XSRF-TOKEN` cookie. Axios and Angular's HttpClient copy that cookie into an `X-XSRF-TOKEN` header automatically; other clients must URL-decode and send it themselves. ## The full login sequence 1. SPA: `GET /sanctum/csrf-cookie` → 204, `XSRF-TOKEN` cookie set. 2. SPA: `POST /login` with credentials and `X-XSRF-TOKEN` → session started through the `web` guard. 3. SPA: `GET /api/user` with the session cookie and a stateful `Referer` → `auth:sanctum` finds the session user. 4. Session expires → later calls get 401 or 419, and the SPA sends the user back to its login page. ## Version and deployment notes | Concern | Laravel 13 / Sanctum 4 | |---|---| | Where to enable | `statefulApi()` in `bootstrap/app.php` | | CSRF class in Sanctum's config | `ValidateCsrfToken`, a deprecated alias of Laravel 13's `PreventRequestForgery` | | Same-site requirement | SPA and API share a top-level domain; subdomains allowed | | Cross-subdomain cookies | session `domain` set to the parent domain; CORS must allow credentials | The CORS and session-domain settings are owned elsewhere, but they are the usual second cause after a missing stateful entry. ## Diagnosing by status code | Symptom | Usual cause | |---|---| | `POST /login` returns 419 | `/sanctum/csrf-cookie` not called, or the client does not send `X-XSRF-TOKEN` (a sibling-subdomain SPA is `same-site`, not `same-origin`, so the token is still needed) | | Login works, `GET /api/user` returns 401 | SPA host (with port) missing from `SANCTUM_STATEFUL_DOMAINS`, or `statefulApi()` not called | | Works on `localhost`, fails on the staging subdomain | session cookie scoped to the API host only; stateful list lacks the staging host | | Works in one browser tab, 401 after a while | session expired; the SPA must send the user back to login | A useful habit is to open the failing request in the browser's network panel and check two things before touching code: does it carry a `Referer` or `Origin` that exactly matches a stateful entry, and did the response to the login request set the session cookie for a domain the API host can read? ## Why not just use tokens? Session mode exists so the SPA never holds a bearer credential. A token in `localStorage` is readable by any injected script and is valid until revoked, while the session cookie is `HttpOnly`, rotates with the session and is protected by the CSRF check that stateful requests go through.
- The SPA runs on the Vite dev server at localhost:5173, login succeeds, but GET /api/user returns 401. Why?With `SANCTUM_STATEFUL_DOMAINS` unset, the stateful list covers `localhost` and `localhost:3000` but not `localhost:5173`, and the match includes the port. The API request is therefore treated as stateless: no session starts, the `web` guard finds no user, and without a Bearer token `auth:sanctum` returns 401. Add `localhost:5173` to the variable.
- What does a stateful API request that skipped /sanctum/csrf-cookie get on a POST?Because the request is stateful, Sanctum runs the CSRF middleware on it. Laravel 13's check first accepts a `Sec-Fetch-Site: same-origin` request, but an SPA on a sibling subdomain or port sends `same-site`, which is not accepted by default. With no `XSRF-TOKEN` cookie there is nothing to echo in `X-XSRF-TOKEN`, so the POST returns 419. GET requests are not checked.
saying these in an interview costs you the question
- statefulApi() registers the /sanctum/csrf-cookie route
- Listing localhost also covers localhost:5173
- The csrf-cookie route returns the CSRF token in a JSON body
- Every API request gets a session once Sanctum is installed
- Stateful matching reads the API's own Host header, not Referer or Origin