Auth
Sanctum: a token or a cookie, depending on who is calling
The mistake
Sanctum gets described as “the API token package for Laravel”, so the natural plan
for a single-page app is: log in, get a token back, keep it in localStorage, and
send it as a Bearer header on every request. That works, and it is the wrong
choice for your own front end.
Sanctum has two authentication paths, not one, and it picks between them by who is calling. A first-party SPA, your own React or Vue app on your own domain, is meant to authenticate with the ordinary session cookie, the same one a Blade app uses. Tokens are for the other case: a mobile app, or a third party, that has no browser session to lean on. Reaching for a token in your own SPA throws away the one real advantage the cookie has, and takes on a risk the cookie does not carry.
The machine
The mechanism chosen, the status and every header the request carries run on the tested reducer, matching how Sanctum authenticates stateful SPA and stateless token requests.
Drive it
The panel boots as a first-party SPA that has already called /sanctum/csrf-cookie
and logged in, one step from a successful request to POST /api/posts.
- Press the highlighted button to send it. The request carries the httpOnly
laravel_sessioncookie and anX-XSRF-TOKENheader. Sanctum treats it as stateful and authenticates it from the server session. No token is involved anywhere. - Switch to a mobile client and send again. Now the request carries an
Authorization: Bearertoken and nothing else, no cookie, no CSRF. Sanctum hashes the token, finds its row, and loads the same user. - Go back to the SPA, drop the XSRF token, and send. A
419before auth even runs. This is the error everyone hits: a state-changing request over the session path needs a matching CSRF token, and you get it by calling/sanctum/csrf-cookiefirst.
The mechanism
Both callers hit the same route, guarded the same way:
Route::post('/api/posts', StorePost::class)->middleware('auth:sanctum');
What differs is what the request carries and how Sanctum reads it.
For a first-party SPA, the flow is stateful. The app calls
GET /sanctum/csrf-cookie once, which sets an XSRF-TOKEN cookie the JavaScript can
read. It logs in through the normal session login, so the server session now holds
the user and the browser holds an httpOnly laravel_session cookie. On every later
request, Sanctum’s EnsureFrontendRequestsAreStateful middleware sees the request
comes from a configured first-party domain, boots the session and CSRF middleware,
and lets the web session guard identify the user. The identity lives in the
server-side session store; the cookie is only an opaque id pointing at it.
For a mobile or third-party client, the flow is stateless. The user creates a
personal access token, which Laravel stores as a SHA-256 hash in the
personal_access_tokens table and hands back in plaintext exactly once. The client
sends it as Authorization: Bearer <token>. There is no cookie and no session, so
EnsureFrontendRequestsAreStateful leaves the request stateless, and Sanctum hashes
the incoming token, finds the matching row, and loads that user. The identity lives
in the token row, and the client holds the only copy of the secret.
That split explains the two failures in the simulator. A 419 is CSRF: the session
path runs the CSRF middleware (VerifyCsrfToken, renamed ValidateCsrfToken in
Laravel 11) before auth, so a state-changing request with no valid
X-XSRF-TOKEN is turned away before Sanctum ever looks at who you are. A 401 is
authentication: CSRF passed but the session has no user, or the bearer token matched
no row. The bearer path never produces a 419, because a token request carries no
cookie for CSRF to protect.
In your code
The SPA side is browser plumbing, not tokens. With axios configured to send
credentials, the whole login is two calls, and every request after them is
authenticated by the cookie:
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
await axios.get('/sanctum/csrf-cookie'); // sets the XSRF-TOKEN cookie
await axios.post('/login', { email, password }); // establishes the session
await axios.post('/api/posts', { title }); // 200, authenticated by the session
The token side is one call, and you keep the plaintext because you only see it once:
$token = $user->createToken('mobile', ['post:create'])->plainTextToken;
// e.g. "3|9x0Kb...": the mobile app stores this and sends it as
// Authorization: Bearer 3|9x0Kb...
In Laravel 11 you turn the stateful SPA path on in bootstrap/app.php, and the same
auth:sanctum guard then serves both callers:
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})
The fine print
- One guard, two attempts.
auth:sanctumuses the session when the request is stateful, and falls back to the bearer token when it is not. You do not choose per route; the request’s own shape decides. - Which domains count as first-party is configuration:
SANCTUM_STATEFUL_DOMAINSlists them. A request from one is treated statefully; anything else is treated as a token client. - Tokens carry abilities and can expire.
createToken('name', ['post:create'])scopes a token, checked with$user->tokenCan('post:create'), and tokens can be given an expiry so a leaked one does not last forever. - The security trade-off is the real reason to prefer the cookie for your own SPA. The session cookie is httpOnly, so a cross-site script cannot read it, but it is sent automatically, which is why it needs CSRF protection. A bearer token is never sent automatically, so it is immune to CSRF, but it is a plain secret: if you keep it somewhere JavaScript can read, a cross-site script can steal it.
- This page models a
POST, so CSRF is in play on the session path. A safeGETwould skip the CSRF check, but the authentication works the same way.
Further reading
- Laravel Sanctum: SPA authentication
is the cookie path in full, including
EnsureFrontendRequestsAreStatefuland the stateful domains. - Laravel Sanctum: API token authentication
covers
createToken, abilities, hashing and expiry for the token path. - Laravel: CSRF protection explains why the cookie
path needs the
X-XSRF-TOKENheader and where the419comes from.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.