Auth Architecture Overview
SaaS4Builders uses Laravel Sanctum v4 for authentication, supporting two modes out of the box: cookie-based sessions for the SPA frontend and token-based auth for API clients. Both modes share the same endpoints, middleware, and permission system.
How Authentication Works
Sanctum provides dual-mode authentication from a single codebase. The frontend chooses which mode to use via the NUXT_PUBLIC_AUTH_MODE environment variable.
Cookie Mode (SPA) Token Mode (API)
───────────────── ────────────────
GET /sanctum/csrf-cookie POST /api/v1/auth/login
POST /api/v1/auth/login ← { accessToken, refreshToken }
← session cookie + XSRF-TOKEN
GET /api/v1/auth/me
GET /api/v1/auth/me Authorization: Bearer <accessToken>
(cookie sent automatically)
Cookie mode is the default for the Nuxt SPA. The browser sends session cookies automatically, and Sanctum validates them via Laravel's session guard. CSRF protection is handled via the XSRF-TOKEN cookie.
Token mode is designed for mobile apps, third-party integrations, or any client that cannot maintain cookies. The client sends a Bearer token in the Authorization header.
NUXT_PUBLIC_AUTH_MODE=cookie (default) or NUXT_PUBLIC_AUTH_MODE=token in your .env. See Environment Configuration for all available variables.Token Lifecycle
Every authentication action (login, register, OAuth) returns a token pair:
- Access token — A Sanctum personal access token stored in the
personal_access_tokenstable. Used for API authorization via theBearerheader. - Refresh token — A custom long-lived token stored in the
refresh_tokenstable. Used exclusively to obtain a new access token when the current one expires.
Refresh tokens expire after 30 days and use family-based rotation for security.
Token Pair Creation
When a user logs in or registers, the RefreshAccessToken action creates both tokens:
public function createTokenPair(User $user, bool $revokeExisting = false): array
{
if ($revokeExisting) {
$user->revokeAllRefreshTokens();
$user->tokens()->delete();
}
$refreshTokenString = Str::random(64);
$family = Str::random(64);
RefreshToken::create([
'user_id' => $user->id,
'token' => hash('sha256', $refreshTokenString),
'family' => $family,
'expires_at' => now()->addDays(self::REFRESH_TOKEN_EXPIRATION_DAYS),
]);
$accessToken = $user->createToken('auth-token')->plainTextToken;
return [
'accessToken' => $accessToken,
'refreshToken' => $refreshTokenString,
];
}
Family-Based Rotation
When a client refreshes its access token, the server rotates the refresh token: the old one is revoked, and a new one is issued in the same family. If a revoked token is ever reused (indicating potential token theft), the entire family is revoked immediately.
Client Server
────── ──────
POST /auth/refresh
{ refresh_token: RT-A }
Validate RT-A → valid, not revoked
Revoke RT-A
Create RT-B (same family)
Create new access token
← { accessToken, refreshToken: RT-B }
POST /auth/refresh (attacker reuses RT-A)
{ refresh_token: RT-A }
RT-A is revoked → THEFT DETECTED
Revoke entire token family
← 401 INVALID_REFRESH_TOKEN
Middleware Stack
Authentication and authorization are enforced through a layered middleware pipeline. Every middleware alias is registered in backend/bootstrap/app.php:
| Alias | Class | Purpose |
|---|---|---|
auth:sanctum | Sanctum built-in | Verifies session cookie or Bearer token |
tenant.resolve | ResolveTenant | Resolves the current tenant from session or header |
tenant.member | EnsureTenantMember | Verifies the user belongs to the resolved tenant |
onboarding.complete | EnsureOnboardingComplete | Blocks access until onboarding is finished |
platform.admin | EnsurePlatformAdmin | Restricts access to platform administrators |
platform.permission | EnsurePlatformPermission | Checks a specific platform permission |
impersonation.active | EnsureImpersonationActive | Requires an active impersonation session |
impersonation.prevent | PreventDuringImpersonation | Blocks access during impersonation |
Standard Pipeline
Most tenant-scoped routes use this middleware chain:
auth:sanctum → tenant.resolve → tenant.member → onboarding.complete
This is declared in backend/routes/api.php:
Route::middleware(['auth:sanctum', 'tenant.resolve'])->group(function (): void {
// Auth routes (logout, me)
Route::middleware(['tenant.member', 'onboarding.complete'])->group(function (): void {
// Tenant-scoped routes (billing, team, usage, etc.)
});
});
Admin routes add platform.admin and impersonation.prevent:
auth:sanctum → impersonation.prevent → platform.admin
Frontend Auth Architecture
The frontend auth system is organized in three layers:
useAuth (composable) ← Facade: login/register/logout + role-based navigation
↓
useAuthStore (Pinia store) ← State: user, tokens, CSRF, impersonation context
↓
useSession (composable) ← Session: checkAuth, periodic refresh, route guards
useAuthStore is the Pinia store that holds all auth state: user, accessToken, refreshToken, impersonation, isInitialized. It handles both cookie and token modes transparently. Key actions: login(), register(), logout(), bootstrap() (calls /auth/me), initialize() (idempotent startup).
useAuth is the facade composable that wraps the store with navigation logic. After login, it redirects platform managers to /manager and regular users to /dashboard. It also exposes role and permission helpers: hasRole(), hasPermission(), hasAnyPermission(), hasAllPermissions().
useSession manages session lifecycle: checkAuth() initializes auth if needed, requireAuth() and requireGuest() act as route guards, and startPeriodicRefresh() optionally refreshes the user profile on an interval.
All three are located in frontend/features/foundation/auth/.
Auth Middleware
Two Nuxt middleware files protect routes:
auth.ts— Initializes the auth store on first navigation, then redirects unauthenticated users to/login?redirect=....tenant.ts— Resolves the current tenant, checks subscription status, enforces billing details completion, and bootstraps entitlements. Platform managers (not impersonating) are redirected to/manager.
Permissions System
SaaS4Builders uses a two-tier permission system:
Platform Level
is_platform_admin— A boolean flag on the User model. Platform admins have access to the entire manager area.- Platform permissions — 30+ fine-grained permissions managed via Spatie's
laravel-permissionpackage. Organized into groups: products, plans, features, entitlements, users, roles, tenants, analytics, settings, content. - Middleware enforcement —
platform.adminchecks the admin flag;platform.permission:Xchecks a specific permission.
Tenant Level
- Tenant roles — Each tenant member has a role:
owner,admin, ormember. Custom roles can be created per tenant. - Tenant permissions — Permissions are attached to roles. The
ownerrole has all permissions by default. - Role hierarchy — Owners cannot be demoted if they are the last owner. Admins can manage members but not other admins.
Frontend Permission Helpers
const { hasPermission, hasAnyPermission, isPlatformAdmin } = useAuth()
// Check a single permission
if (hasPermission('billing.manage')) { ... }
// Check any of several permissions
if (hasAnyPermission(['team.invite', 'team.manage'])) { ... }
The usePlatformPermissions composable provides similar helpers specifically for the manager area.
isPlatformAdmin is a flag on the User model, not a role. A user can be a platform admin AND a member of a tenant simultaneously. Platform admin status is independent of tenant membership.Endpoint Reference
All authentication-related endpoints at a glance:
Auth Endpoints
| Method | Endpoint | Auth | Throttle | Purpose |
|---|---|---|---|---|
POST | /api/v1/auth/register | No | 10/min | Register user + tenant |
POST | /api/v1/auth/login | No | 5/min | Email/password login |
POST | /api/v1/auth/refresh | No | 10/min | Rotate token pair |
POST | /api/v1/auth/forgot-password | No | 5/min | Send password reset email |
POST | /api/v1/auth/reset-password | No | 5/min | Apply new password |
GET | /api/v1/auth/oauth/providers | No | — | List enabled OAuth providers |
GET | /api/v1/auth/oauth/{provider}/redirect | No | 10/min | Redirect to OAuth provider |
GET | /api/v1/auth/oauth/{provider}/callback | No | 10/min | Handle OAuth callback |
POST | /api/v1/auth/oauth/exchange | No | 10/min | Exchange OAuth code for session/tokens |
POST | /api/v1/auth/logout | Yes | — | Revoke tokens, end session |
GET | /api/v1/auth/me | Yes | — | Current user + tenant + impersonation |
Onboarding Endpoints
| Method | Endpoint | Auth | Throttle | Purpose |
|---|---|---|---|---|
POST | /api/v1/onboarding/start | No | 5/min | Register + create checkout |
GET | /api/v1/onboarding/status | Yes | 10/min | Poll subscription status |
POST | /api/v1/onboarding/retry-checkout | Yes | 10/min | Retry failed payment |
PATCH | /api/v1/onboarding/complete-setup | Yes | 10/min | Finalize with billing info |
Impersonation Endpoints
| Method | Endpoint | Auth | Middleware | Purpose |
|---|---|---|---|---|
POST | /api/v1/admin/impersonation/start | Yes | platform.admin | Start impersonating a user |
POST | /api/v1/admin/impersonation/stop | Yes | impersonation.active | Stop impersonation |
GET | /api/v1/admin/impersonation/status | Yes | impersonation.active | Get impersonation status |
What's Next
- Registration & Onboarding — The two signup paths and the Stripe checkout integration
- Login, Logout & Token Refresh — Email/password, OAuth popup flow, and token rotation
- Impersonation — How platform admins can view the app as any tenant user