| name | sanctum-auth |
| description | Laravel Sanctum dual-mode authentication for this monorepo. Use when implementing login, session management, PAT tokens, CSRF cookie flow, or auth middleware. Triggers on tasks involving authentication, authorization, Sanctum, SPA cookies, Bearer tokens, or user identity. |
| frameworks | ["laravel","sanctum"] |
| languages | ["php","typescript","dart"] |
| category | auth |
| updated | "2026-04-29T00:00:00.000Z" |
Sanctum Dual-Mode Auth Skill
Quick Reference
Modes:
- Web SPA — cookie-based stateful session (
X-XSRF-TOKEN header)
- Mobile — Personal Access Token (PAT), 90-day expiry,
Authorization: Bearer {token}
Auth Flow Overview
Web SPA: Mobile (Flutter):
1. GET /sanctum/csrf-cookie 1. POST /api/v1/auth/mobile/register
2. POST /api/v1/auth/web/login 2. POST /api/v1/auth/mobile/login → { token }
3. All requests: credentials: 3. All requests: Authorization: Bearer {token}
include + X-XSRF-TOKEN header
Backend — Laravel Controllers
Mobile Login (PAT)
class LoginController extends Controller
{
public function __invoke(LoginRequest $request): JsonResponse
{
if (! Auth::attempt($request->only('email', 'password'))) {
throw ValidationException::withMessages([
'email' => [__('auth.failed')],
]);
}
$user = Auth::user();
$token = $user->createToken('mobile', ['*'], now()->addDays(90));
return response()->json([
'token' => $token->plainTextToken,
'token_type' => 'Bearer',
'user' => new UserResource($user),
]);
}
}
Mobile Logout (revoke PAT)
public function __invoke(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
Web Login (cookie session)
class LoginController extends Controller
{
public function __invoke(LoginRequest $request): JsonResponse
{
if (! Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => [__('auth.failed')],
]);
}
$request->session()->regenerate();
return response()->json(['user' => new UserResource(Auth::user())]);
}
}
Backend — Route Protection
Route::prefix('v1')->group(function () {
Route::get('/health', HealthController::class);
Route::prefix('auth/web')->group(function () {
Route::post('/register', Web\RegisterController::class);
Route::post('/login', Web\LoginController::class);
});
Route::prefix('auth/mobile')->group(function () {
Route::post('/register', Mobile\RegisterController::class);
Route::post('/login', Mobile\LoginController::class);
Route::post('/logout', Mobile\LogoutController::class)->middleware('auth:sanctum');
});
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', fn (Request $request) => response()->json($request->user()));
Route::apiResource('todos', TodoController::class);
});
});
Backend — Sanctum Config
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost:3001')),
'expiration' => null,
'guard' => ['web'],
Key .env variables:
SANCTUM_STATEFUL_DOMAINS=localhost:3001
SESSION_DOMAIN=localhost
SESSION_SAME_SITE=lax
SESSION_SECURE_COOKIE=false # true in production (HTTPS)
CORS_ALLOWED_ORIGINS=http://localhost:3001
Frontend (Next.js) — Sanctum Cookie Flow
The sanctumBrowserFetcher in apps/web/services/sanctum/browser-fetcher.ts:
- Reads
XSRF-TOKEN cookie set by GET /sanctum/csrf-cookie
- Auto-fetches the CSRF cookie if absent
- Sends
X-XSRF-TOKEN: <token> header on mutating requests
- Passes
credentials: "include" on all requests
import type { CustomFetcher } from "@repo/api-client"
export const sanctumBrowserFetcher: CustomFetcher = async <T>(
url: string,
init: RequestInit
): Promise<T> => {
const method = ((init.method as string) || "GET").toUpperCase()
if (method !== "GET" && method !== "HEAD") {
let token = readXsrfToken()
if (!token) {
await fetchCsrfCookie()
token = readXsrfToken()
}
if (token) {
const headers = new Headers(init.headers as HeadersInit)
headers.set("X-XSRF-TOKEN", token)
init = { ...init, headers }
}
}
const response = await fetch(`${origin}${url}`, {
...init,
credentials: "include",
})
}
Wire it up in the app once at startup:
import { configureFetcher } from "@repo/api-client"
import { sanctumBrowserFetcher } from "@/services/sanctum/browser-fetcher"
configureFetcher(sanctumBrowserFetcher)
Mobile (Flutter) — PAT Flow
The createDio factory in apps/mobile/lib/services/api/api_client.dart:
- Reads PAT from
FlutterSecureStorage on every request
- Injects
Authorization: Bearer <pat> header
- On 401: clears stored PAT and calls
onUnauthorized callback
// Usage — wire up at app start (main.dart)
final storage = SecureStorageService();
final dio = await createDio(storage, onUnauthorized: () {
// Navigate to login screen
ref.read(authProvider.notifier).handleExpiredSession();
});
Auth repository pattern:
// features/auth/data/auth_repository.dart
Future<String> login(String email, String password) async {
final response = await _dio.post(ApiConstants.signIn, data: {
'email': email,
'password': password,
});
final token = response.data['token'] as String;
await _storage.storePat(token);
return token;
}
Future<void> logout() async {
await _dio.post(ApiConstants.signOut);
await _storage.deletePat();
}
Accessing Current User
$user = $request->user();
if ($request->user()->tokenCan('admin')) { ... }
Sliding Token Expiry Middleware
The SlidingTokenExpiry middleware renews the PAT expiry on each authenticated request:
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$token = $request->user()?->currentAccessToken();
if ($token instanceof PersonalAccessToken) {
$token->forceFill(['expires_at' => now()->addDays(90)])->save();
}
return $response;
}
Environment Variables
| Variable | Purpose |
|---|
SANCTUM_STATEFUL_DOMAINS | Domains that get cookie auth (e.g., localhost:3001) |
SESSION_DOMAIN | Cookie domain |
SESSION_SAME_SITE | lax in dev, strict in production |
SESSION_SECURE_COOKIE | false locally, true in production |
CORS_ALLOWED_ORIGINS | Origins allowed to send cookies |