Laravel Sanctum for SPAs and mobile apps

Separate browser sessions from mobile bearer tokens, implement CSRF-aware login, and test authorization independently of token abilities.
Choose the authentication mechanism by client
For Laravel developers serving both a browser SPA and a mobile client, Sanctum provides two authentication paths. The first-party SPA uses a Laravel session cookie. The mobile client sends a bearer token. Both can reach an auth:sanctum endpoint, but the credentials have different issuance, expiry and logout behavior.
These examples were verified on 12 October 2026 with Laravel 12.69.3, Sanctum 4.3.3 and PHP 8.5.6. The application needs Sanctum’s token table, HasApiTokens on its User model and an existing credential-verification flow. The service below does not implement password authentication or MFA.
Prepare the session path
Enable statefulApi in the withMiddleware callback in bootstrap/app.php. Configure Sanctum’s stateful hosts, including development ports. This determines which frontend requests get Sanctum’s cookie/session middleware. An apparently successful login followed by a 401 from the API can mean that the API request did not enter that stateful path; inspect the request origin, cookie transport and configured hosts before changing authentication code.
The helper uses relative URLs and credentials: same-origin. It is deliberately limited to a frontend and API served from the same origin. A separate-subdomain deployment requires compatible cookie-domain and credentialed CORS settings and an HTTP client configured to include credentials across origins. Do not copy this helper unchanged into that deployment.
The fixture’s POST /login endpoint validates the input, attempts authentication through the web guard, regenerates the session on success and applies login throttling. It returns a 204 success or JSON errors. Sanctum supplies the CSRF-cookie endpoint and authentication integration; it does not supply this entire login endpoint.
Send the CSRF cookie value as a header
The browser accepts the session and XSRF-TOKEN cookies from /sanctum/csrf-cookie. Native fetch sends eligible cookies but does not copy XSRF-TOKEN into the X-XSRF-TOKEN header for you. The helper reads and URL-decodes that cookie before submitting the credential JSON. The session cookie remains HttpOnly; JavaScript only reads the separate CSRF cookie.
The helper throws on failure so its caller can show an escaped error message and restore the submit button in a finally block. It does not automatically retry credentials: a 419 can ask the user to restart login, while a 429 should respect the server’s throttling response. A network failure also rejects the promise. Do not log the credential payload or response bodies.
export async function login(email, password) {
const csrf = await fetch('/sanctum/csrf-cookie', {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
if (!csrf.ok) throw new Error('Could not initialize the login session.');
const cookie = document.cookie.split('; ')
.find((part) => part.startsWith('XSRF-TOKEN='));
if (!cookie) throw new Error('The CSRF cookie is unavailable.');
const response = await fetch('/login', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(cookie.slice('XSRF-TOKEN='.length)),
},
body: JSON.stringify({ email, password }),
});
if (response.status === 419) throw new Error('Session expired. Please try again.');
if (response.status === 422) throw new Error('Check your login details.');
if (response.status === 429) throw new Error('Too many attempts. Try again later.');
if (!response.ok) throw new Error('Login failed. Please try again.');
}
Issue a limited token after mobile authentication
After a mobile issuance endpoint has verified the user’s credentials and any required account or MFA checks, this action creates a token named for the device. It grants only profile:read and sets a seven-day expiry. Seven days is a demonstration policy, not a recommended lifetime for every product. Choose the lifetime and renewal flow for your application’s risk and usability requirements.
The action trims the label and rejects an empty value or more than 100 bytes. The limit is in bytes, so a multibyte label may contain fewer than 100 characters. Validate the same constraint in the calling HTTP request. Return the plaintext token once over HTTPS; the mobile app must retain it in its platform’s protected credential storage. Exclude token issuance responses from logs.
<?php
namespace App\Actions;
use App\Models\User;
use InvalidArgumentException;
final class IssueMobileToken
{
public function handle(User $authenticatedUser, string $deviceName): string
{
$deviceName = trim($deviceName);
if ($deviceName === '' || strlen($deviceName) > 100) {
throw new InvalidArgumentException('Use a device name of 1 to 100 bytes.');
}
return $authenticatedUser->createToken(
$deviceName,
['profile:read'],
now()->addDays(7)
)->plainTextToken;
}
}
Do not confuse an ability with permission
Protect the endpoint with auth:sanctum, then apply its resource authorization policy. A bearer token with profile:read must not gain a write permission simply because its user can edit a profile through a browser. Require the appropriate ability as well as the user’s permission. For first-party session requests, tokenCan returns true, so an ability test by itself cannot protect someone else’s resource.
Logout also depends on the credential. Browser logout should end the web session and invalidate its CSRF token. Revoking one mobile device requires deleting that device’s personal token. Logging out of the browser does not delete every personal token, and deleting one token does not log the browser out. Provide separate product behavior for disconnecting a device and ending a session.
Verified failures and deployment limits
The browser fixture sends real HTTP requests through Laravel’s middleware. A credential POST without the CSRF header returns 419. Wrong credentials reach the helper as its login-details error and leave protected requests at 401. Successful login replaces the server-side session ID and permits the owner’s export request. Another user’s browser session receives 403 for that export.
Logout returns 204, the next protected request returns 401, and a pre-logout CSRF token is rejected. To test expiry without waiting two hours, the fixture ages its session file beyond the configured lifetime; Laravel then rejects the stored session, and fresh CSRF initialization plus login restores access. Repeated failed logins also exercise the real throttle middleware and the helper’s 429 error.
The framework suite separately tests real stored bearer tokens, including expiry and revocation. Its mobile issuer test verifies the stored ability list, trimmed device name and seven-day expiry. Eight JavaScript unit tests isolate the helper’s request construction and failure handling; those stubs alone would not prove cookie transport or CSRF enforcement.
These results apply to synthetic accounts and same-origin loopback HTTP in the fixture. They do not verify production HTTPS cookie flags, a cross-subdomain CORS deployment, mobile credential storage, MFA, renewal or shared-account membership rules. Test those at the actual frontend/API origins before release. The fixture is a reproducible authentication example, not a complete production login system.
Reproduce the examples
The archive contains the exact snippets, locked Laravel dependencies, feature tests and browser runner. Start with its README; Docker, Python 3.12+ and Node 22+ are required. It uses synthetic accounts and does not require access to our repository.
Download the source and tests (.zip)
