Files
crewli/dev-docs/AUTH_ARCHITECTURE.md
bert.hausmans 513ca519b2 security: migrate auth tokens to httpOnly cookies (hybrid bearer token approach)
Backend:
- CookieBearerToken middleware reads httpOnly cookie and injects Authorization
  header before Sanctum validates (prepended to API middleware group)
- SetAuthCookie trait provides cookie creation/expiry helpers with per-app
  cookie names (crewli_admin_token, crewli_app_token, crewli_portal_token)
- LoginController sets token via Set-Cookie, removes it from JSON body
- LogoutController expires the auth cookie on logout
- AuthRefreshController (POST /auth/refresh) rotates tokens with new cookie
- InvitationController accept also sets token via cookie, not JSON body
- All cookies: httpOnly, SameSite=Strict, Secure (in production)

Frontend (all three SPAs):
- Removed all localStorage token storage (apps/app, apps/portal)
- Removed all JS-readable cookie token storage (apps/admin)
- Removed Authorization: Bearer header interceptors from axios
- Auth stores now rely on GET /auth/me to validate httpOnly cookie
- Admin app: new Pinia auth store replaces useCookie-based auth pattern
- withCredentials: true ensures browser sends cookies automatically

Fixes security findings A13-1 (localStorage tokens) and A13-2 (admin
cookie flags). Tokens are now invisible to JavaScript.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:06:44 +02:00

7.1 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. Three 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
Admin localhost:5173 admin.crewli.app Super admin / platform management
App localhost:5174 app.crewli.app Organiser dashboard
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.

App Cookie Name Domain Secure httpOnly SameSite Max-Age
Admin crewli_admin_token .crewli.app (prod) / localhost (dev) Yes (prod) Yes Strict 7 days
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. Checks for any of the three cookie names in the request
  2. If found, sets the Authorization: Bearer header on the request
  3. Sanctum's existing token validation processes the header normally

If an Authorization header is already present (e.g. from the portal token flow), the middleware skips cookie injection.

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


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_ADMIN_URL .env / config/app.php Admin SPA origin (cookie name resolution + CORS)
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)