Three verification methods (TOTP authenticator, email code, backup codes), trusted device management with 30-day expiry, role-based enforcement for super_admin and org_admin, admin reset capability, and full test coverage (46 tests). Modifies login flow to support MFA challenge/response with temporary session tokens stored in cache. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 KiB
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:
- Cookie-based (
auth:sanctum): volunteers and crew who have auser_id— login with email/password, httpOnly cookie set on login - Token-based (
portal.tokenmiddleware): artists, suppliers, press — stateless per-request token viaAuthorization: Bearerheader 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:
- Validates credentials via
Auth::attempt() - Creates a Sanctum personal access token
- Resolves the cookie name from the
Originheader - Returns user data in the JSON body (no token in body)
- Attaches the token as a
Set-Cookieheader with httpOnly flag
Validation
The CookieBearerToken middleware (registered before auth:sanctum in the API middleware stack):
- Reads the
Origin(orReferer) header to identify which app is making the request - Resolves the correct cookie name for that app (e.g. portal origin →
crewli_portal_token) - Reads only that cookie and sets
Authorization: Beareron the request - 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):
- Revokes the current access token
- Creates a new token
- Returns user data with a new httpOnly cookie
- 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=Strictis stricter thanLax— even top-level navigations from other sites will not include the cookie
Reference: OWASP CSRF Prevention Cheat Sheet — 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
localStorageorsessionStorage- 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
- The portal generates a unique token per artist/supplier, stored as a SHA-256 hash in the
artistsorproduction_requeststable - The plaintext token is sent to the person (e.g. via email link)
- The person accesses a portal URL with the token as a query parameter or
Authorization: Bearerheader PortalTokenMiddlewarevalidates the hash, resolves the person and event context- The request proceeds with
portal_context,portal_person, andportal_eventattributes
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-Fingerprintheader must be sent with login requests - Can be listed, individually revoked, or all revoked by the user
- Are stored in the
trusted_devicestable 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_aton 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— alwaysorg_admin— always- Any user in an organisation with
settings.enforce_mfa = true - Any user with
mfa_enforced = trueflag
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_resetevent - 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 |