Skip to content
SaaS4Builders
Authentication

Auth Architecture Overview

How SaaS4Builders handles authentication with Sanctum dual-mode, token pairs, middleware, and role-based permissions.

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.

Auth mode is configured in your frontend environment. Set 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_tokens table. Used for API authorization via the Bearer header.
  • Refresh token — A custom long-lived token stored in the refresh_tokens table. 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:

backend/app/Application/Auth/Actions/RefreshAccessToken.php
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
If a revoked refresh token is reused, all tokens in that family are revoked. Both the legitimate user and the attacker lose access, forcing a fresh login. This is a deliberate security trade-off — it prioritizes safety over convenience.

Middleware Stack

Authentication and authorization are enforced through a layered middleware pipeline. Every middleware alias is registered in backend/bootstrap/app.php:

AliasClassPurpose
auth:sanctumSanctum built-inVerifies session cookie or Bearer token
tenant.resolveResolveTenantResolves the current tenant from session or header
tenant.memberEnsureTenantMemberVerifies the user belongs to the resolved tenant
onboarding.completeEnsureOnboardingCompleteBlocks access until onboarding is finished
platform.adminEnsurePlatformAdminRestricts access to platform administrators
platform.permissionEnsurePlatformPermissionChecks a specific platform permission
impersonation.activeEnsureImpersonationActiveRequires an active impersonation session
impersonation.preventPreventDuringImpersonationBlocks 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:

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-permission package. Organized into groups: products, plans, features, entitlements, users, roles, tenants, analytics, settings, content.
  • Middleware enforcementplatform.admin checks the admin flag; platform.permission:X checks a specific permission.

Tenant Level

  • Tenant roles — Each tenant member has a role: owner, admin, or member. Custom roles can be created per tenant.
  • Tenant permissions — Permissions are attached to roles. The owner role 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

frontend/features/foundation/auth/composables/useAuth.ts
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

MethodEndpointAuthThrottlePurpose
POST/api/v1/auth/registerNo10/minRegister user + tenant
POST/api/v1/auth/loginNo5/minEmail/password login
POST/api/v1/auth/refreshNo10/minRotate token pair
POST/api/v1/auth/forgot-passwordNo5/minSend password reset email
POST/api/v1/auth/reset-passwordNo5/minApply new password
GET/api/v1/auth/oauth/providersNoList enabled OAuth providers
GET/api/v1/auth/oauth/{provider}/redirectNo10/minRedirect to OAuth provider
GET/api/v1/auth/oauth/{provider}/callbackNo10/minHandle OAuth callback
POST/api/v1/auth/oauth/exchangeNo10/minExchange OAuth code for session/tokens
POST/api/v1/auth/logoutYesRevoke tokens, end session
GET/api/v1/auth/meYesCurrent user + tenant + impersonation

Onboarding Endpoints

MethodEndpointAuthThrottlePurpose
POST/api/v1/onboarding/startNo5/minRegister + create checkout
GET/api/v1/onboarding/statusYes10/minPoll subscription status
POST/api/v1/onboarding/retry-checkoutYes10/minRetry failed payment
PATCH/api/v1/onboarding/complete-setupYes10/minFinalize with billing info

Impersonation Endpoints

MethodEndpointAuthMiddlewarePurpose
POST/api/v1/admin/impersonation/startYesplatform.adminStart impersonating a user
POST/api/v1/admin/impersonation/stopYesimpersonation.activeStop impersonation
GET/api/v1/admin/impersonation/statusYesimpersonation.activeGet impersonation status

What's Next