# Crewli — Authentication Architecture > Version: 1.0 — April 2026 > Audience: security auditors, backend developers --- ## 1. Authentication Overview Crewli uses **stateless token-based authentication** via Laravel Sanctum. Two SPA clients communicate with a single REST API. Tokens are stored exclusively in **httpOnly cookies** set by the server — they are never exposed to JavaScript via response bodies, localStorage, or JS-readable cookies. ### Client Applications | App | URL (dev) | URL (prod) | Purpose | |-----|-----------|------------|---------| | App | localhost:5174 | crewli.app | Organiser dashboard + platform admin (`/platform/*` for super_admin) | | Portal | localhost:5175 | portal.crewli.app | Volunteers, artists, suppliers | ### Access Modes The Portal supports two access modes: 1. **Cookie-based** (`auth:sanctum`): volunteers and crew who have a `user_id` — login with email/password, httpOnly cookie set on login 2. **Token-based** (`portal.token` middleware): artists, suppliers, press — stateless per-request token via `Authorization: Bearer` header or `?token=` query parameter. No cookies involved. --- ## 2. Cookie Specification | App | Cookie Name | Domain | Secure | httpOnly | SameSite | Max-Age | |-----|-------------|--------|--------|----------|----------|---------| | App | `crewli_app_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days | | Portal | `crewli_portal_token` | `.crewli.app` (prod) / `localhost` (dev) | Yes (prod) | Yes | Strict | 7 days | Each SPA gets its own cookie name to prevent shared auth state between apps. The cookie domain is configured via `SESSION_DOMAIN` in `.env`. --- ## 3. Token Lifecycle ### Creation On successful login (`POST /auth/login`), the server: 1. Validates credentials via `Auth::attempt()` 2. Creates a Sanctum personal access token 3. Resolves the cookie name from the `Origin` header 4. Returns user data in the JSON body (no token in body) 5. Attaches the token as a `Set-Cookie` header with httpOnly flag ### Validation The `CookieBearerToken` middleware (registered before `auth:sanctum` in the API middleware stack): 1. Reads the `Origin` (or `Referer`) header to identify which app is making the request 2. Resolves the correct cookie name for that app (e.g. portal origin → `crewli_portal_token`) 3. Reads only that cookie and sets `Authorization: Bearer` on the request 4. Sanctum's existing token validation processes the header normally **Cross-app isolation:** In local development, both SPAs share `localhost` (different ports). Browsers do not scope cookies by port, so both app cookies are sent with every API request. The middleware prevents cross-app authentication by only reading the cookie that matches the requesting app's Origin header. Without this, logging into one app would authenticate the other. If the `Origin` header is absent (e.g. server-to-server requests), the middleware falls back to the first available cookie. If an `Authorization` header is already present (e.g. from the portal token flow), the middleware skips cookie injection entirely. ### Rotation `POST /auth/refresh` (authenticated endpoint): 1. Revokes the current access token 2. Creates a new token 3. Returns user data with a new httpOnly cookie 4. Logs the refresh event Clients should call this endpoint periodically (recommended: every 24 hours) to rotate tokens. ### Expiration Tokens expire after **7 days** (configured in `config/sanctum.php`). After expiration, Sanctum rejects the token and the client receives a 401. The cookie's `Max-Age` matches the token expiration. ### Revocation Tokens are revoked on: - **Logout** (`POST /auth/logout`): current token deleted, cookie expired - **Password reset**: all user tokens revoked - **Password change**: other session tokens revoked - **Email change verification**: all user sessions revoked - **Token refresh**: old token replaced with new one --- ## 4. CSRF Protection **CSRF tokens are not required.** The `SameSite=Strict` cookie attribute prevents the browser from sending the auth cookie on cross-origin requests. This means: - A malicious site cannot forge authenticated requests because the cookie is never attached to cross-origin submissions - `SameSite=Strict` is stricter than `Lax` — even top-level navigations from other sites will not include the cookie Reference: [OWASP CSRF Prevention Cheat Sheet — SameSite Cookie Attribute](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute) --- ## 5. Attack Surface Analysis ### XSS — Token Theft **Mitigated.** The bearer token is stored in an `httpOnly` cookie and is never present in: - The JSON response body - `localStorage` or `sessionStorage` - JS-readable cookies (`document.cookie`) Even if an XSS vulnerability exists, the attacker cannot read the token. They can make authenticated requests from the user's browser session, but cannot exfiltrate the token for use elsewhere. ### CSRF — Cross-Site Request Forgery **Mitigated.** `SameSite=Strict` prevents the browser from attaching the cookie to any request originating from a different site, including form submissions and top-level navigations. ### Network Interception — Token Theft **Mitigated in production.** The `Secure` flag ensures the cookie is only sent over HTTPS connections. In development (localhost), `Secure` is disabled to allow HTTP. ### Server Compromise — Token Theft **Partially mitigated.** Sanctum hashes tokens in the `personal_access_tokens` table using SHA-256. An attacker with database read access sees hashed tokens, not plaintext values. However, an attacker with full server access could intercept tokens in memory. ### Token Fixation **Not applicable.** Tokens are generated server-side using cryptographically secure random values. The client never provides or influences the token value. --- ## 6. Portal Token-Based Flow (Artists / Suppliers) This flow is separate from the httpOnly cookie system and is NOT affected by this architecture. ### How It Works 1. The portal generates a unique token per artist/supplier, stored as a SHA-256 hash in the `artists` or `production_requests` table 2. The plaintext token is sent to the person (e.g. via email link) 3. The person accesses a portal URL with the token as a query parameter or `Authorization: Bearer` header 4. `PortalTokenMiddleware` validates the hash, resolves the person and event context 5. The request proceeds with `portal_context`, `portal_person`, and `portal_event` attributes ### Security Properties - Tokens are hashed at rest (SHA-256) - No cookies or sessions involved — each request is independently authenticated - Token validity is tied to event status (draft and closed events reject tokens) - No user account required — the token IS the identity --- ## 7. Middleware Stack (Relevant Portion) ``` Request → CookieBearerToken (reads cookie → injects Authorization header) → auth:sanctum (validates bearer token) → Controller ``` For portal token routes: ``` Request → portal.token (validates portal-specific token) → Controller ``` --- ## 8. Configuration Reference | Setting | Location | Purpose | |---------|----------|---------| | `SESSION_DOMAIN` | `.env` | Cookie domain (`.crewli.app` in prod, `localhost` in dev) | | `FRONTEND_APP_URL` | `.env` / `config/app.php` | App SPA origin | | `FRONTEND_PORTAL_URL` | `.env` / `config/app.php` | Portal SPA origin | | `sanctum.expiration` | `config/sanctum.php` | Token TTL (7 days = 10080 minutes) | --- ## 9. Multi-Factor Authentication (MFA) Crewli supports enterprise-grade MFA with three verification methods, trusted device management, role-based enforcement, and admin reset capability. ### 9.1 Verification Methods | Method | Type | Expiry | Notes | |--------|------|--------|-------| | TOTP | Authenticator app (Google Authenticator, Authy, etc.) | 30s per code | Primary, most secure. Secret stored encrypted. | | Email code | 6-digit code sent via EmailService | 10 min | Fallback for TOTP users, or standalone method. Rate-limited: 1 code per 60s. | | Backup codes | 10 single-use codes (XXXX-XXXX format) | Never | Generated at MFA setup. Stored as bcrypt hashes. Last resort recovery. | ### 9.2 Login Flow with MFA ``` Client API │ │ │ POST /auth/login │ │ { email, password } │ │ ─────────────────────────────────►│ │ │ ── Validate credentials │ │ ── Check MFA enabled? │ │ │ ┌── MFA NOT enabled ──────────┤ │ │ Return auth token (cookie)│ │ │ │ │ └── MFA enabled ─────────────┤ │ │ │ │ ├── Trusted device? ─ YES ─ Return auth token (skip MFA) │ │ │ │ └── No trusted device ────┤ │ Return mfa_required │ │ + mfa_session_token │ │ ◄────────────────────────────────│ │ │ │ POST /auth/mfa/verify │ │ { mfa_session_token, code, │ │ method, trust_device? } │ │ ─────────────────────────────────►│ │ │ ── Verify code │ │ ── Optionally trust device │ ◄── auth token (cookie) ─────────│ ``` ### 9.3 MFA Session After successful password authentication, if MFA is required, the API creates a temporary MFA session: - Stored in Redis/Cache with prefix `mfa_session:` - TTL: 10 minutes - Contains: `user_id`, `ip_address`, `created_at` - IP address is checked on verification — if it changes, the session is invalidated - Session is consumed (deleted) after successful MFA verification ### 9.4 Trusted Devices Users can opt to trust a device during MFA verification. Trusted devices: - Skip MFA on subsequent logins for 30 days - Are identified by a SHA-256 hash of `device_fingerprint + user_id` - The `X-Device-Fingerprint` header must be sent with login requests - Can be listed, individually revoked, or all revoked by the user - Are stored in the `trusted_devices` table with ULID primary key ### 9.5 Backup Codes - 10 codes generated at MFA setup (format: `XXXX-XXXX`) - Stored as bcrypt hashes — plain codes shown to user only once - Each code is single-use (marked `used` + `used_at` on verification) - Can be regenerated (requires TOTP verification) - Input normalization: spaces and dashes stripped, case-insensitive ### 9.6 Role-Based Enforcement MFA is required (enforced) for: - `super_admin` — always - `org_admin` — always - Any user in an organisation with `settings.enforce_mfa = true` - Any user with `mfa_enforced = true` flag When MFA is required but not yet set up, login succeeds but includes `mfa_setup_required: true` flag. The frontend should redirect to MFA setup. ### 9.7 Admin Reset Platform admins (`super_admin`) can force-disable MFA for any user via `POST /admin/users/{user}/reset-mfa`. This: - Clears all MFA data (secret, backup codes, email codes, trusted devices) - Logs the action in the activity log with `mfa.admin_reset` event - The user must re-enable MFA on next login if enforcement policies apply ### 9.8 Database Tables | Table | PK Type | Purpose | |-------|---------|---------| | `users` (MFA columns) | — | `mfa_enabled`, `mfa_method`, `mfa_secret` (encrypted), `mfa_confirmed_at`, `mfa_enforced` | | `mfa_backup_codes` | auto-increment | Hashed single-use recovery codes | | `mfa_email_codes` | auto-increment | Temporary 6-digit email verification codes | | `trusted_devices` | ULID | Device trust records with expiry | ### 9.9 Key Files | File | Purpose | |------|---------| | `app/Services/MfaService.php` | Central MFA logic — setup, verification, backup codes, trusted devices, enforcement | | `app/Enums/MfaMethod.php` | TOTP, EMAIL, BACKUP_CODE enum | | `app/Http/Controllers/Api/V1/Auth/MfaSetupController.php` | Authenticated MFA setup/disable/status endpoints | | `app/Http/Controllers/Api/V1/Auth/MfaVerifyController.php` | Login-flow MFA verification (unauthenticated) | | `app/Http/Controllers/Api/V1/Auth/TrustedDeviceController.php` | Trusted device management | --- ## 10. Impersonation ### 10.1 Overview Platform admins (`super_admin`) can impersonate other users to investigate issues. The system uses a **header-based approach** — the admin's own httpOnly cookie session is never modified. Instead, the frontend sends an `X-Impersonate-User` header, and a middleware swaps the auth context per-request. ### 10.2 Security Controls | Control | Implementation | |---------|---------------| | **MFA at start** | Admin must provide a valid TOTP, email, or backup code to begin impersonation | | **Admin MFA required** | Admin's MFA must be enabled (`mfa_enabled = true`) | | **No super_admin targets** | Cannot impersonate another super_admin | | **No nesting** | Cannot start a second impersonation while one is active | | **No double-impersonation** | Cannot impersonate a user already being impersonated by another admin | | **IP pinning** | If the admin's IP changes mid-session, the session is terminated | | **Sensitive route blocking** | Auth, MFA, password, email, profile, and impersonation routes are blocked during impersonation | | **Sliding TTL** | Sessions auto-expire after 60 minutes; each request extends the window | | **Immutable audit** | Every session is recorded in `impersonation_sessions` with no soft deletes | ### 10.3 Flow ``` Admin (with httpOnly cookie) API │ │ │ POST /admin/impersonate/{user} │ │ { reason, mfa_code, mfa_method } │ │ ──────────────────────────────────────────►│ │ │ ── Verify admin is super_admin │ │ ── Verify admin has MFA enabled │ │ ── Verify target is not super_admin │ │ ── Check no active session (admin or target) │ │ ── Verify MFA code │ │ ── Create ImpersonationSession (DB) │ │ ── Store session ID in cache (60 min TTL) │ ◄── session + user data ─────────────────│ │ │ │ GET /any-route │ │ Cookie: crewli_app_token=... │ │ X-Impersonate-User: {target_user_id} │ │ ──────────────────────────────────────────►│ │ │ ── CookieBearerToken: inject admin auth │ │ ── auth:sanctum: validate admin token │ │ ── HandleImpersonation middleware: │ │ 1. Read X-Impersonate-User header │ │ 2. Check sensitive route block list │ │ 3. Validate cache session (admin+target) │ │ 4. Check IP matches session │ │ 5. Extend sliding TTL │ │ 6. Store impersonator in request attributes │ │ 7. Swap auth: setUser(targetUser) │ │ 8. Increment actions_count │ │ ── Controller sees targetUser as auth user │ ◄── response (as target user) ───────────│ │ │ │ POST /admin/stop-impersonation │ │ (NO X-Impersonate-User header) │ │ ──────────────────────────────────────────►│ │ │ ── Admin auth from cookie │ │ ── End session (DB + cache) │ ◄── admin user data ─────────────────────│ ``` ### 10.4 Blocked Routes During Impersonation The `HandleImpersonation` middleware blocks these route prefixes when the `X-Impersonate-User` header is present: - `auth/password` — password management - `auth/logout` — would affect admin's session - `auth/mfa` — MFA setup/verify/status - `auth/trusted-devices` — device trust management - `me/profile` — profile updates - `me/change-password` — password changes - `me/change-email` — email changes - `admin/impersonate` — no nesting via API - `verify-email-change` — email verification ### 10.5 Activity Log Integration During an active impersonation session, `AppServiceProvider` hooks into `Activity::saving()` and automatically adds: - `impersonated_by.user_id` — the admin's user ID - `impersonated_by.name` — the admin's full name - `impersonated_by.email` — the admin's email - `impersonation_session_id` — the session ID This applies to **all** activity log entries, not just impersonation-specific events. ### 10.6 Database Table **`impersonation_sessions`** — immutable audit table (no soft deletes) | Column | Type | Notes | |--------|------|-------| | `id` | ULID | PK | | `admin_id` | ULID FK | → users | | `target_user_id` | ULID FK | → users | | `reason` | string | Admin-provided reason for impersonation | | `mfa_method` | string(20) | Which MFA method was used to verify | | `ip_address` | string(45) | Admin's IP at session start | | `user_agent` | text nullable | Admin's user agent | | `started_at` | timestamp | Session start time | | `ended_at` | timestamp nullable | NULL = still active | | `expires_at` | timestamp | Auto-expiry time (sliding, 60 min) | | `end_reason` | string(50) nullable | manual, expired, ip_changed, admin_kill_all | | `actions_count` | unsigned int | Number of API requests made during session | **Indexes:** `(admin_id, ended_at)`, `(target_user_id, ended_at)`, `(started_at)` ### 10.7 Key Files | File | Purpose | |------|---------| | `app/Services/ImpersonationService.php` | Session lifecycle, MFA verification, cache management | | `app/Http/Middleware/HandleImpersonation.php` | Per-request header validation, auth swap, route blocking | | `app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php` | Start, stop, status, send-mfa-code endpoints | | `app/Http/Requests/Admin/StartImpersonationRequest.php` | Validation for start request | | `app/Models/ImpersonationSession.php` | Eloquent model with `HasUlids`, `scopeActive()` | | `app/Http/Resources/Admin/ImpersonationSessionResource.php` | API resource for session data |