Skip to main content

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, memberships collections)
  • 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) applies authMiddleware → tenantContextMiddleware → requireTenant globally to all /api/v1 routes. Only /health and /ready are public. Auth routes must be explicitly exempted from this pipeline.
  • Frontend (ui/src/context/auth-context.tsx) stores { tenantId, apiKey } in localStorage. Every page checks auth.isConfigured (tenantId is non-empty). The API client (ui/src/api/client.ts) sends x-tenant-id and optional x-api-key headers — never cookies.

We need auth for two distinct populations before pilot:

  1. Internal admins (SecurityV0 team, 2-5 people) — need immediate access, already have GitHub identities in the SecurityV0 org.
  2. 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-Id tenant 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/orgs to verify SecurityV0 org membership.
  • Rejection if user is not an org member.
  • Session issued as signed JWT in HttpOnly cookie.
  • 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 endpointRequired role
POST /api/v1/ingest/normalized-graphadmin (connector ingestion)
POST /api/v1/ingest/connector-reportadmin (connector ingestion)
PATCH /api/v1/findings/:id/statusadmin
POST /api/v1/admin/allowed-clientsadmin
DELETE /api/v1/admin/allowed-clients/:emailadmin

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=false gap).
  • 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.

  • 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.com email 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.