| name | authentication-patterns |
| description | Use when implementing authentication, authorization, or access control. Covers Laravel Sanctum, OAuth 2.0, RBAC with Spatie Permissions, multi-tenant auth, API token management, MFA, and session security. |
| user-invocable | false |
| allowed-tools | ["Read","Write","Bash","Grep","Glob"] |
Authentication Patterns
All patterns enforce multi-tenant isolation via branch_id scoping.
Decision: Sanctum vs Passport vs JWT
| Approach | Use When |
|---|
| Sanctum (SPA cookies) | First-party SPA on same domain |
| Sanctum (API tokens) | First-party mobile or third-party with simple scopes |
| Passport (OAuth 2.0) | Third-party apps needing authorization code flow |
| JWT (raw) | Stateless cross-service communication only |
Laravel Sanctum
SPA Authentication (Cookie-Based)
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')),
'expiration' => 120,
Route::post('/login', function (Request $request) {
$request->validate(['email' => ['required', 'email'], 'password' => ['required']]);
if (! Auth::attempt($request->only('email', 'password'))) {
throw ValidationException::withMessages(['email' => ['Invalid credentials.']]);
}
$request->session()->regenerate();
return response()->json(['data' => new UserResource(Auth::user())]);
});
API Token Authentication
$token = $user->createToken('api-client', ['invoices:read', 'invoices:write', 'reports:read']);
Route::middleware('auth:sanctum')->group(function () {
Route::get('/invoices', [InvoiceController::class, 'index'])->middleware('ability:invoices:read');
Route::post('/invoices', [InvoiceController::class, 'store'])->middleware('ability:invoices:write');
});
$schedule->command('sanctum:prune-expired --hours=48')->daily();
RBAC (Spatie Permissions)
$permissions = [
'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.delete', 'invoices.approve',
'reports.view', 'reports.export', 'users.view', 'users.create', 'users.edit', 'settings.manage',
];
foreach ($permissions as $p) { Permission::firstOrCreate(['name' => $p, 'guard_name' => 'web']); }
$admin = Role::firstOrCreate(['name' => 'admin']);
$admin->givePermissionTo(Permission::all());
$accountant = Role::firstOrCreate(['name' => 'accountant']);
$accountant->givePermissionTo(['invoices.view', 'invoices.create', 'invoices.edit', 'reports.view']);
Laravel Policies (Tenant-Aware)
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool {
return $user->hasPermissionTo('invoices.view') && $user->branch_id === $invoice->branch_id;
}
public function update(User $user, Invoice $invoice): bool {
return $user->hasPermissionTo('invoices.edit')
&& $user->branch_id === $invoice->branch_id
&& $invoice->status !== 'approved';
}
public function approve(User $user, Invoice $invoice): bool {
return $user->hasPermissionTo('invoices.approve')
&& $user->branch_id === $invoice->branch_id
&& $user->id !== $invoice->created_by;
}
}
Multi-Tenant Auth Middleware
class EnsureBranchScope
{
public function handle(Request $request, Closure $next): Response {
if (Auth::check()) {
app()->instance('current_branch_id', Auth::user()->branch_id);
if ($request->has('branch_id') && (int) $request->branch_id !== Auth::user()->branch_id) {
abort(403, 'Cross-tenant access denied.');
}
}
return $next($request);
}
}
Branch Switching (Company Admins)
public function switchBranch(Request $request): JsonResponse {
$branch = Branch::findOrFail($request->validate(['branch_id' => 'required|exists:branches,id'])['branch_id']);
abort_unless($request->user()->company_id === $branch->company_id, 403);
abort_unless($request->user()->hasRole('company-admin'), 403);
$request->user()->update(['branch_id' => $branch->id]);
$request->session()->regenerate();
return response()->json(['data' => new UserResource($request->user()->fresh())]);
}
Multi-Factor Authentication (TOTP)
public function enableMfa(Request $request): JsonResponse {
$secret = (new Google2FA())->generateSecretKey();
$request->user()->update(['mfa_secret' => encrypt($secret), 'mfa_enabled' => false]);
return response()->json([
'qr_url' => (new Google2FA())->getQRCodeUrl(config('app.name'), $request->user()->email, $secret),
]);
}
public function confirmMfa(Request $request): JsonResponse {
$valid = (new Google2FA())->verifyKey(decrypt($request->user()->mfa_secret), $request->validate([ => ])[]);
(, , );
= ::(, fn () => ::())->();
->()->([ => , => (())]);
()->([ => ]);
}
{
{
= ->();
(->() && ! ->mfa_enabled) ()->([ => ], );
(->mfa_enabled && ! ()) ()->([ => ], );
();
}
}
Password and Session Security
'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()->symbols()->uncompromised()],
'lifetime' => 120, 'encrypt' => true, 'secure' => true, 'http_only' => true, 'same_site' => 'lax',
Frontend Auth (Vue 3 + Pinia)
export const useAuthStore = defineStore('auth', {
state: (): { user: User | null; isAuthenticated: boolean } => ({ user: null, isAuthenticated: false }),
actions: {
async login(email: string, password: string) {
await api.get('/sanctum/csrf-cookie');
await api.post('/login', { email, password });
await this.fetchUser();
},
async fetchUser() { this.user = (await api.get('/api/user')).data; this.isAuthenticated = true; },
can(permission: string): boolean { return this.user?.permissions?.includes(permission) ?? false; },
},
});
router.( (to, , next) => {
auth = ();
(to.. && !auth.) ({ : , : { : to. } });
(to.. && !auth.(to.. )) ({ : });
();
});
Security Headers Middleware
class SecurityHeaders {
public function handle(Request $request, Closure $next): Response {
$response = $next($request);
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
return $response;
}
}
Security Audit Checklist