skip to content

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?

level: middleimportance: should knowfreq 55%

answer

  1. session middleware on API routes
  2. EnsureFrontendRequestsAreStateful in the api group
  3. Referer or Origin matched, port included
  4. 204 plus an XSRF-TOKEN cookie
  5. X-XSRF-TOKEN header echoed back

basics

~10 s

statefulApi() 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 s

In 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
<?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

for a junior

Remember the order: enable statefulApi(), list the SPA's host in SANCTUM_STATEFUL_DOMAINS, call /sanctum/csrf-cookie, then post to the login route.

for a middle

Explain how EnsureFrontendRequestsAreStateful matches Referer or Origin against the stateful list, port included, and which middleware it then runs.

for a senior

Diagnose 401 versus 419 from an SPA quickly: a missing stateful entry, a missing XSRF cookie, or a cross-subdomain cookie and CORS mismatch.

for a principal

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