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

Simulator · Sanctum token vs session

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.

POST /login: the session now holds the authenticated user, and the browser has an httpOnly laravel_session cookie.

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_session cookie and an X-XSRF-TOKEN header. 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: Bearer token 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 419 before 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-cookie first.

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:sanctum uses 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_DOMAINS lists 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 safe GET would skip the CSRF check, but the authentication works the same way.

Further reading

Spotted a problem, or have a way to make this clearer? Suggest an improvement.