ADR-012: User Authentication Strategy
Status
Superseded (2026-04-09) by ADR-016: Multi-Tenant Authentication Architecture and ADR-017: WorkOS as Authentication Provider.
Why superseded: This ADR was written for a pre-pilot, <10 user constraint and proposed a self-built dual-mode approach (GitHub OAuth for internal admins, email magic link for clients). It did not address:
- Multi-tenant data model (no
tenants,users,membershipscollections) - Per-tenant SSO configuration or enterprise SAML/OIDC
- SCIM / Directory Sync
- Admin Portal for customer IT admin self-service
- URL-scoped tenant routing (
/t/:slug/...) for shareable links - SecurityV0 super-admin cross-tenant switcher
- Per-tenant configuration (Jira, branding, feature flags)
- Programmatic / M2M / MCP access
These are now explicit requirements. ADR-016 defines the vendor-independent multi-tenant authentication architecture; ADR-017 selects WorkOS as the provider. Read those ADRs for the current direction. This ADR is kept for historical reference.
Original status: Accepted (2026-03-01)
Context
The platform has no production authentication. The current state:
- Auth middleware (
src/api/middleware/auth.ts) has two stub mechanisms: API key validation and JWT decoding without signature verification (documented as G01/G02 security gaps). - Production runs with
REQUIRE_AUTH=false— auth is entirely disabled. - Architecture spec says "OAuth 2.0 for web UI, API keys for programmatic" but no IdP, login flow, or user management has been built.
- Middleware pipeline (
src/api/app.ts) appliesauthMiddleware → tenantContextMiddleware → requireTenantglobally to all/api/v1routes. Only/healthand/readyare public. Auth routes must be explicitly exempted from this pipeline. - Frontend (
ui/src/context/auth-context.tsx) stores{ tenantId, apiKey }in localStorage. Every page checksauth.isConfigured(tenantId is non-empty). The API client (ui/src/api/client.ts) sendsx-tenant-idand optionalx-api-keyheaders — never cookies.
We need auth for two distinct populations before pilot:
- Internal admins (SecurityV0 team, 2-5 people) — need immediate access, already have GitHub identities in the SecurityV0 org.
- External clients (whitelisted pilot organizations, handful of individual emails) — need frictionless onboarding, no password setup, no SSO integration requirement.
Constraints
- Pre-scale phase: <10 total users, <5 organizations.
- Stack is Express + MongoDB + React (not Next.js) — vendor SDKs optimized for Next.js add friction.
- Existing
X-Tenant-Idtenant isolation model must be preserved. - Platform is read-only and handles sensitive security data — sessions must be secure.
- Must be implementable in 2-3 days, not weeks.
Decision
Self-built dual-mode authentication with two strategies in a new src/api/auth/ module:
Strategy 1: GitHub OAuth + Org Membership Gate (Admins)
- GitHub OAuth App registered in the SecurityV0 org.
- OAuth flow with PKCE (supported by GitHub since July 2025).
- On callback, server-side call to
GET /user/orgsto verify SecurityV0 org membership. - Rejection if user is not an org member.
- Session issued as signed JWT in HttpOnly cookie.
Strategy 2: Email Magic Link with Whitelist (Clients)
- Server-side whitelist of approved emails (MongoDB collection or env var for the first few).
- User submits email → server checks whitelist → generates cryptographic token → emails a login link via Resend.
- User clicks link → server verifies token (hash match, expiry, one-time use) → session issued.
- Generic response on submit ("If authorized, a link was sent") to prevent email enumeration.
Tenant Model (Resolved)
Admins are tenant-scoped, not cross-tenant. Each admin session carries a single tenantId assigned at login time (from a configurable admin-to-tenant mapping). Admins who need to view a different tenant log out and log back in, or we provide a tenant-switcher that issues a new session cookie. This preserves the existing invariant that req.auth.tenantId is always authoritative, and tenantContextMiddleware does not need precedence changes.
Rationale: Cross-tenant browsing (admin picks X-Tenant-Id freely) would require an explicit allowed-tenant-set check that does not exist today. The current tenantContextMiddleware prefers req.auth.tenantId over the header (const tenantId = fromAuth ?? fromHeader), so an admin session would be pinned to the JWT tenant regardless. Changing that precedence without a policy layer creates cross-tenant access by convention rather than enforcement. At <5 tenants, tenant switching via re-login or cookie reissue is acceptable.
Bearer JWT Path (De-scoped for Pilot)
The existing unsigned Bearer JWT path in auth.ts is removed for pilot. It is not safe without issuer/audience/JWKS validation, and no consumer depends on it today (production runs with REQUIRE_AUTH=false). If a future integration needs Bearer auth, it will be added with proper jose JWKS verification per the G01 fix plan.
Authorization Enforcement
Session auth alone is not sufficient. The plan includes a requireRole middleware that gates routes by role. The write surface is small and enumerated:
| Write endpoint | Required role |
|---|---|
POST /api/v1/ingest/normalized-graph | admin (connector ingestion) |
POST /api/v1/ingest/connector-report | admin (connector ingestion) |
PATCH /api/v1/findings/:id/status | admin |
POST /api/v1/admin/allowed-clients | admin |
DELETE /api/v1/admin/allowed-clients/:email | admin |
All other /api/v1 GET routes are accessible to both admin and client roles, scoped by the session's tenantId.
No Vendor SDK
We deliberately avoid Clerk, Stytch, Auth.js, or similar at this stage because:
- We already have MongoDB and a tenant isolation model — a vendor adds a second session/identity store.
- GitHub org-gate is not natively supported by most vendors (they offer GitHub as social login, not org membership verification).
- The user base is <10 people — vendor overhead is not justified.
- Full control over the auth flow avoids coupling to vendor-specific session models.
Alternatives Considered
Clerk / Stytch / Kinde (managed auth vendor)
Pre-built React components, hosted session management, social + magic link out of the box. Rejected because: adds external dependency for <10 users, GitHub org-gate requires custom logic anyway, and vendor session models conflict with our existing X-Tenant-Id tenant isolation. Revisit when a client requires SAML SSO.
Auth.js (NextAuth) v5
Open-source, supports GitHub + email providers (Resend). Rejected because: Auth.js is Next.js-first — Express adapter exists but is not well-maintained. Would require adapting session handling to our middleware pattern.
Cloudflare Access Zero Trust
Already evaluated for the docs site (see plans/happy-swimming-hopper.md). Works well for static sites but adds latency and complexity for an API + SPA architecture. Does not issue JWTs consumable by our API without additional integration.
Password-based auth
Rejected. Passwords add credential storage liability, reset flows, and friction for pilot onboarding. Magic links are strictly simpler for a whitelist of <20 emails.
Cross-tenant admin browsing
Admins set X-Tenant-Id freely to browse any tenant. Rejected: requires an allowed-tenant-set policy layer that does not exist, and the current tenantContextMiddleware precedence (fromAuth ?? fromHeader) would silently ignore the header for cookie-authenticated sessions. Introducing precedence flips creates cross-tenant access by convention rather than policy.
Consequences
Positive
- Production gets real authentication (closes the
REQUIRE_AUTH=falsegap). - Zero-friction admin onboarding — team members log in with existing GitHub accounts.
- Zero-friction client onboarding — whitelisted emails receive a magic link, no account creation.
- No vendor dependency, no external session store, no billing.
- Tenant isolation invariant preserved — JWT tenant is authoritative, no precedence ambiguity.
- Removing the unsigned Bearer JWT path eliminates the G01 attack surface without needing JWKS.
- Explicit role-based route authorization prevents client sessions from reaching write surfaces.
Negative
- Self-built auth requires careful implementation (token entropy, hash-only storage, atomic consumption, cookie security). The implementation plan includes a security checklist.
- Magic links have a known corporate email scanner issue (Microsoft Defender pre-clicks links). Mitigated by an intermediate confirmation page.
- No self-service user management — admins add whitelisted emails manually (acceptable at <20 users).
- Admins cannot browse multiple tenants in a single session (acceptable at <5 tenants).
When to Graduate
Move to a managed vendor (WorkOS, Stytch B2B, or Kinde) when:
- A client requires SAML SSO (enterprise procurement requirement).
- A client requires SCIM provisioning (automated user lifecycle from their IdP).
- The whitelist grows past ~20 entries and needs a self-service invitation flow.
- Cross-tenant admin browsing is needed — vendor multi-org models handle this natively.
Related
- Security gaps G01/G02:
sv0-documentation/docs/analysis/2026-02-20-etl-pipeline-strengthening-plan.md - Current auth middleware:
sv0-platform/src/api/middleware/auth.ts - Current middleware pipeline:
sv0-platform/src/api/app.ts(lines 61-77) - Current tenant resolution:
sv0-platform/src/api/middleware/tenant-context.ts(line 14:fromAuth ?? fromHeader) - Auth types:
sv0-platform/src/shared/types/auth.ts - Frontend auth context:
sv0-platform/ui/src/context/auth-context.tsx - Frontend API client:
sv0-platform/ui/src/api/client.ts - Implementation plan:
sv0-documentation/docs/plans/2026-03-01-user-authentication-plan.md
Addendum: Cloudflare Access (2026-03-31)
Cloudflare Access (Zero Trust) is now deployed on app.securityv0.com and dev.securityv0.com as a perimeter authentication layer. This is separate from the application-level auth described in this ADR:
- Cloudflare Access — protects the door. Requires team login (
@securityv0.comemail OTP or GitHub org) before any request reaches the origin server. CI/CD uses service tokens to bypass. - Application-level auth (this ADR) — controls what authenticated users can do. OAuth + magic links for user identity, tenant mapping, role-based access. Not yet implemented.
Both layers are independent and complementary. See Access Protection for CF Access configuration.