Laravel API Authentication with Sanctum: Complete SPA and Mobile Guide
Learn how to implement Laravel Sanctum for both SPA cookie-based authentication and mobile token auth. Covers Sanctum vs Passport, CORS setup, token expiry, refresh patterns, and PHPUnit testing in one complete guide.
Laravel API Authentication with Sanctum: Complete SPA and Mobile Guide
By Elhassane Mehdioui — Full Stack Web Developer specialised in Laravel
When building modern applications with Laravel, authentication is one of the first architectural decisions you face. Should you use cookies or tokens? What about Passport versus Sanctum? How do you handle CORS, token expiry, and refresh patterns without leaking security holes?
This guide answers all of those questions. Whether you are building a React or Vue SPA served from the same domain, or a React Native mobile app consuming your API from a remote device, Laravel Sanctum covers both scenarios cleanly. By the end you will have a production-ready authentication layer backed by PHPUnit tests.
Sanctum vs Passport: Choosing the Right Tool
Before writing a single line of code, you need to pick the right package. Both live in the Laravel ecosystem, but they solve different problems.
Laravel Passport implements the full OAuth 2.0 specification. It ships with authorization codes, client credentials, personal access tokens, and implicit grants. If you are building a public API that third-party developers will consume, or you need fine-grained OAuth scopes shared across multiple applications, Passport is the right choice. The trade-off is complexity: it requires additional database tables, key generation, and a deeper understanding of OAuth flows.
Laravel Sanctum is purpose-built for two specific use cases:
- Single-page applications on the same top-level domain as the API (cookie-based session auth).
- Mobile or third-party clients that need simple API tokens (token-based auth).
For the vast majority of SaaS products, internal APIs, and mobile backends, Sanctum is the correct choice. It is lighter, easier to reason about, and ships with Laravel out of the box from version 8 onward. This guide focuses entirely on Sanctum.
Installation and Initial Setup
Laravel Sanctum is included by default in fresh Laravel 11 installs. If you are on an older project, install it manually.
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
This publishes the sanctum.php config file and creates the personal_access_tokens table that stores API tokens.
Next, add the HasApiTokens trait to your User model.
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
Session-Based SPA Authentication
For SPAs served from the same top-level domain as your Laravel backend, Sanctum piggybacks on Laravel's existing session and cookie infrastructure. There are no tokens involved — the browser stores a session cookie, just like a traditional server-rendered app.
Configuring CORS
This is the step that trips up most developers. Your SPA origin must be allowed in two places: Laravel's CORS configuration and Sanctum's stateful domains list.
In config/cors.php:
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['http://localhost:3000'],
'supports_credentials' => true,
The supports_credentials flag is critical. Without it, the browser will not send cookies on cross-origin requests.
In config/sanctum.php:
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')),
Set SANCTUM_STATEFUL_DOMAINS in your .env file to match the domain your SPA runs on.
The Authentication Flow
Before logging in, the SPA must fetch a CSRF token. Make this request from your frontend before calling the login endpoint.
GET /sanctum/csrf-cookie
Sanctum sets a XSRF-TOKEN cookie. Axios reads this automatically and attaches it as the X-XSRF-TOKEN header on subsequent requests. If you are using Fetch API you need to do this manually.
// Axios configuration for SPA auth
axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://localhost:8000';
await axios.get('/sanctum/csrf-cookie');
await axios.post('/login', { email, password });
Login and Logout Endpoints
In routes/web.php (not api.php, because session auth uses the web middleware):
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
class AuthController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($credentials)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$request->session()->regenerate();
return response()->json(['user' => Auth::user()]);
}
public function logout(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json(['message' => 'Logged out']);
}
}
Protect your API routes with the auth:sanctum middleware in routes/api.php:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn(Request $r) => $r->user());
Route::apiResource('posts', PostController::class);
});
Token-Based Mobile Authentication
For mobile apps or any client that cannot share a cookie with your Laravel backend, Sanctum issues opaque API tokens stored in the personal_access_tokens table.
Issuing Tokens at Login
public function mobileLogin(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
'device_name' => 'required|string|max:255',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$token = $user->createToken($request->device_name)->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
]);
}
The device_name field is a human-readable label (e.g., "iPhone 15 Pro") that appears in your token management UI. The plainTextToken is only available immediately after creation — Sanctum stores a hashed version. Store this token securely on the device (Keychain on iOS, Keystore on Android).
Sending the Token
Every subsequent API request must include the token as a Bearer token in the Authorization header.
Authorization: Bearer 1|abc123xyz...
In a React Native app using Axios:
const api = axios.create({
baseURL: 'https://api.yourapp.com',
headers: {
Authorization: `Bearer ${storedToken}`,
Accept: 'application/json',
},
});
Token Abilities (Scopes)
Sanctum supports lightweight token abilities as an alternative to full OAuth scopes.
$token = $user->createToken('mobile-app', ['posts:read', 'posts:write'])->plainTextToken;
Check abilities inside your controllers or middleware:
if ($request->user()->tokenCan('posts:write')) {
// allow the action
}
Token Expiry and the Refresh Token Pattern
By default, Sanctum tokens do not expire. For production applications, especially mobile, you want short-lived tokens combined with a refresh mechanism.
Setting Token Expiry
In config/sanctum.php:
'expiration' => 60, // minutes; null means no expiry
This causes expired tokens to be rejected by the auth:sanctum middleware.
Implementing a Refresh Token Pattern
Sanctum does not ship a native refresh token system, but the pattern is straightforward to implement manually. Issue two tokens at login: a short-lived access token and a longer-lived refresh token with a dedicated ability.
public function mobileLogin(Request $request)
{
// ... validation and credential check ...
// Delete previous tokens for this device to avoid accumulation
$user->tokens()->where('name', $request->device_name)->delete();
$accessToken = $user->createToken($request->device_name, ['*'], now()->addMinutes(60))->plainTextToken;
$refreshToken = $user->createToken($request->device_name . '-refresh', ['token:refresh'], now()->addDays(30))->plainTextToken;
return response()->json([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => 3600,
]);
}
public function refresh(Request $request)
{
$user = $request->user();
if (!$user->tokenCan('token:refresh')) {
return response()->json(['message' => 'Not a refresh token'], 403);
}
// Revoke the used refresh token
$request->user()->currentAccessToken()->delete();
$accessToken = $user->createToken('mobile-app', ['*'], now()->addMinutes(60))->plainTextToken;
$refreshToken = $user->createToken('mobile-app-refresh', ['token:refresh'], now()->addDays(30))->plainTextToken;
return response()->json([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => 3600,
]);
}
Route the refresh endpoint outside the standard auth:sanctum group so expired access tokens do not block it — but still protect it with auth:sanctum so the refresh token itself is validated.
Route::post('/auth/refresh', [AuthController::class, 'refresh'])->middleware('auth:sanctum');
Revoking Tokens
Always provide users a way to revoke tokens (e.g., a "sign out all devices" feature).
// Revoke the current token
$request->user()->currentAccessToken()->delete();
// Revoke all tokens for the user
$request->user()->tokens()->delete();
Testing with PHPUnit
A robust test suite is not optional. Here is a complete example covering login, authenticated requests, token expiry, and logout.
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AuthTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_login_and_receive_token(): void
{
$user = User::factory()->create(['password' => bcrypt('secret')]);
$response = $this->postJson('/api/auth/login', [
'email' => $user->email,
'password' => 'secret',
'device_name' => 'test-device',
]);
$response->assertOk()->assertJsonStructure(['access_token', 'refresh_token']);
}
public function test_invalid_credentials_return_401(): void
{
User::factory()->create(['email' => 'a@b.com', 'password' => bcrypt('secret')]);
$this->postJson('/api/auth/login', [
'email' => 'a@b.com',
'password' => 'wrong',
'device_name' => 'test',
])->assertStatus(401);
}
public function test_authenticated_user_can_access_protected_route(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['*']);
$this->getJson('/api/user')->assertOk()->assertJsonFragment(['email' => $user->email]);
}
public function test_unauthenticated_request_returns_401(): void
{
$this->getJson('/api/user')->assertUnauthorized();
}
public function test_user_can_logout_and_token_is_revoked(): void
{
$user = User::factory()->create(['password' => bcrypt('secret')]);
$token = $user->createToken('test')->plainTextToken;
$this->withToken($token)->postJson('/api/auth/logout')->assertOk();
// Token should now be revoked
$this->withToken($token)->getJson('/api/user')->assertUnauthorized();
}
public function test_token_with_insufficient_ability_is_rejected(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:read']); // no write ability
$this->postJson('/api/posts', ['title' => 'Test'])->assertForbidden();
}
}
Use Sanctum::actingAs($user, $abilities) in feature tests to bypass the full HTTP login flow while still exercising ability checks correctly.
Production Checklist
Before going live, run through these items.
- Set
SESSION_SECURE_COOKIE=trueandSESSION_SAME_SITE=laxin production.env. - Configure
SANCTUM_STATEFUL_DOMAINSto your exact production SPA domain, not a wildcard. - Add a scheduled command to prune expired tokens:
php artisan sanctum:prune-expired --hours=24. Register it inroutes/console.phporapp/Console/Kernel.php. - Store mobile tokens in the OS secure keychain, never in plain AsyncStorage or localStorage.
- Log token creation events for audit trails using Laravel's event system (
TokenCreated). - Rate-limit your login endpoint with
RateLimiteror thethrottlemiddleware to prevent brute-force attacks.
Conclusion
Laravel Sanctum hits the sweet spot for the majority of real-world applications. The cookie-based flow for SPAs is secure by default — CSRF protection is built in, and credentials never leave the server. The token flow for mobile clients is straightforward enough to implement in an afternoon while still supporting abilities, expiry, and per-device revocation.
The patterns covered here — CORS configuration, the refresh token implementation, and the PHPUnit suite — give you a foundation you can extend confidently. Pair this with Laravel's authorization policies and form request validation and you have a complete, testable, production-ready API authentication layer.
Elhassane Mehdioui is a Full Stack Web Developer specialised in Laravel, building scalable APIs and SPA backends for SaaS products.