Authentication, end-to-end
Read this first. If you're a developer joining the project or an agent landing in the codebase, this is the single overview that ties everything together. Every other auth doc is the deep-dive on one slice of what's described here.
The surface is: one identity provider (WorkOS), one cookie-seal slot, one super-admin signal, one redirect-URI source.
The 30-second summary
There are exactly two ways a request authenticates against the platform API:
| You are… | Credential | Where the identity comes from |
|---|---|---|
| A human in a browser | sv0_session iron-session cookie | WorkOS hosted login → /auth/callback mints the cookie |
| An agent or service | Authorization: Bearer <jwt> (or Authorization: Bearer sv0_* for connector keys) | WorkOS-issued JWT or platform-issued connector API key |
The same Express middleware pipeline handles both. The pipeline runs auth first, then tenant + permission resolution. There is no path where tenant or permissions resolve before the principal is authenticated.
End-to-end request flow
┌────────────────────────┐
Internet ──── HTTPS ──────────▶│ Cloudflare Access │ Layer 1: network perimeter
│ (dev + PR previews) │ • Browsers: hosted login
│ prod is OPEN │ • CI: CF service-token headers
└────────────┬───────────┘
│
▼
┌────────────────────────┐
│ Caddy (TLS) → nginx │
│ → API container │
└────────────┬───────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ Express pipeline (in order, src/api/app.ts) │
├───────────────────────────────────────────────────────────────────────────┤
│ 1. createBearerTokenMiddleware ← AUTH layer │
│ • Authorization: Bearer present? verify JWT (or sv0_* api key) │
│ • Sub prefix dispatch: user_* → delegated_agent context │
│ client_* → machine context │
│ sv0_* → connector api_key context │
│ • Legacy `X-Api-Key: sv0_*` header also accepted (sv0-connectors) │
│ • If no Authorization header and no api_key: pass through │
│ │
│ 2. createSessionMiddleware ← AUTH layer │
│ • sv0_session cookie present? unseal → req.authContext.user │
│ • If neither this nor bearer populated context: requireAuth=true→401│
│ │
│ 3. createAutomationRoutes ← bootstrap path │
│ • POST /api/v1/automation/browser-sessions │
│ • Exchanges a delegated_agent JWT for a short-lived sv0_session │
│ cookie (Pattern B). Mounted here because it needs req.authContext │
│ set but has no tenant slug. │
│ │
│ 4. readonlyAutomationMiddleware │
│ • Blocks non-safe HTTP methods on automation-bootstrapped sessions │
│ when read_only=true (prod default). │
│ │
│ 5. createConnectorApiKeysRoutes ← admin path (super-admin gated) │
│ • CRUD for /api/v1/admin/connector-api-keys/* │
│ • Mounted BEFORE tenant middleware: admin paths have no tenant slug │
│ (#645 P1). Cookie-only (rejects delegated_agent sessions). │
│ │
│ 6. createTenantMiddleware ← TENANT layer │
│ • Cookie path: tenant from /t/:slug URL (or x-tenant-id fallback) │
│ • Bearer path: tenant from JWT org_id (NOT from a header) │
│ │
│ 7. createMembershipMiddleware ← AUTHZ layer │
│ • Resolve user perms from membership role + super-admin │
│ • For delegated_agent: intersect with agent_scopes ∩ env_policy │
│ • For machine: scopes from JWT, no user-perm intersection │
└───────────────────────────────────────────────────────────────────────────┘
│
▼
Route handler
(req.authContext is populated)
The IDOR guard at
app.ts:158-165(#347) refuses to construct the app in production unless bothauthProviderandstorageAdapterare wired. This means production cannot fall back to the test-only legacy path that lacks bearer/session middleware.
Flow 1 — Human (browser, cookie session)
- User hits
app.securityv0.com/login(prod is open at the network layer; dev is behind CF Access — see Layer 1 below). /loginredirects to WorkOS AuthKit hosted login. Theredirect_uriparam is derived per-request from the request host and validated againstWORKOS_REDIRECT_URI_ALLOWED_HOSTS(single-host prod is a one-element list; dev/PR-previews use*.securityv0.com).- User authenticates (Google/email/etc.) at WorkOS. WorkOS redirects to
/auth/callback?code=…. /auth/callbackcallsauthenticateWithCode, thenlistOrganizationMemberships(userId)to compute super-admin (is_super_admin = memberships.some(m => m.organizationId === WORKOS_SUPER_ADMIN_ORG_ID)).- The route upserts the user in the local
usersmirror with the canonicalis_super_adminvalue, creates ansv0_sessioncookie sealed withSESSION_COOKIE_PASSWORD, and redirects to the requestedreturn_to. - Subsequent requests carry the cookie.
createSessionMiddlewareunseals it and readsreq.authContext.user.is_super_admindirectly from the local mirror — the DB row is the source of truth, not the cookie. Revocation window: super-admin sessions are TTL-capped at 8 hours (SUPER_ADMIN_SESSION_TTL_MS); regular sessions at 24 hours (SESSION_TTL_MS). Removing a user from the WorkOS super-admin org takes effect at the earlier of: their next login (which writes a newis_super_admin: falseto the DB row), or an explicitPOST /auth/logout(which setssessions_revoked_atand is checked on every subsequent request). There is no automatic propagation when WorkOS org membership changes — staff revocation requires either waiting for TTL expiry or triggering logout.
Files:
src/api/routes/auth.ts—/loginand/auth/callbacksrc/api/auth/providers/workos-provider.ts—handleCallback,getOrganizationMemberships(publicAuthProvidermethod; the cookie callback and the bearer JIT-upsert both call it)src/api/auth/session.ts— iron-session config,SESSION_TTL_MS,SUPER_ADMIN_SESSION_TTL_MSsrc/api/middleware/auth-middleware.ts—createSessionMiddleware
Failure mode. A WorkOS API error during the membership lookup degrades the user to non-super-admin for that login — they see
/no-accessuntil their next login. See architecture §16.2 for the on-call triage rule.
Flow 2 — Agent / machine (bearer JWT or connector API key)
The bearer middleware dispatches on what the credential looks like.
2a. Staff CLI — device_code (delegated_agent)
For a SecurityV0 engineer running tooling from their own laptop that should be attributed to that engineer.
Engineer's laptop ── npm run auth:login ──▶ WorkOS device_code flow
(claude-code Connect App)
│
▼
JWT: sub=user_<their-id>
│
▼
Authorization: Bearer <jwt>
│
▼
bearer middleware sees user_*
→ delegated_agent context
→ introspection: which OAuth App? → claude-code
→ membership middleware:
effective = user_perms ∩
agent_scopes(api:read, ui:session:create) ∩
env_policy (read-only in prod)
- Connect App:
claude-code(STAGING_/PROD_WORKOS_APP_CLAUDECODE_*env vars). - Token TTL: ~1 hour. CLI auto-refreshes via the refresh token at
~/.config/sv0/auth.json. - Token attribution: the human who logged in. Audit logs record
provider_user_id. - Effective permissions: the intersection makes the request read-only by default in prod even when the bound user is admin. The
claude-codeagent client cannot do writes regardless of who owns it.
Files:
scripts/cli/auth.ts— laptop-side device_code flowsrc/api/auth/agent-clients.ts— registry (claude-codeis the only entry today)src/api/middleware/bearer-token-middleware.ts—attachDelegatedAgentContextsrc/api/auth/providers/workos-provider.ts—resolveAgentClientId(introspection)src/api/middleware/auth-middleware.ts—applyDelegatedAgentScopesincreateMembershipMiddleware
2b. CI / service principal — client_credentials
For non-interactive jobs (visual-review, deploy-seed) that act as a service, not a human.
GitHub workflow ── curl POST /oauth2/token ──▶ WorkOS Connect App: ci-staging-m2m
(STAGING_CI_M2M_CLIENT_ID/SECRET)
│
▼
JWT: sub=client_<connect-app-id>
org_id=<bound-tenant>
│
▼
Authorization: Bearer <jwt>
│
▼
bearer middleware sees client_*
→ machine context (req.authContext.machine)
→ req.authContext.user is null
→ tenant from JWT org_id, scopes from JWT
- Connect App:
ci-staging-m2m. NoprincipalUserIdfield. Pure service principal — no human attribution. - Token attribution: the Connect App itself.
- No introspection —
client_*tokens are self-attributing viasuband don't go throughapplyDelegatedAgentScopes. Scopes on the token are honored as-is.
Files:
.github/workflows/visual-review.yml,pr-preview-admin.yml,deploy-dev.yml(the seed step) — inlinecurlmintssrc/api/middleware/bearer-token-middleware.ts—attachMachineContext
2c. Connector API key — sv0_*-prefixed bearer
For connector workers ingesting normalized graph data into /api/v1/ingest/*. Long-lived, server-issued, scope-restricted to ingest paths.
Connector worker ── curl ──▶ Authorization: Bearer sv0_prod_<key>
│
▼
bearer middleware sees sv0_ prefix
→ verifyApiKey() (SHA256 lookup in DB)
→ tenant embedded in the key record
→ scope: connector:ingest only
→ REJECT if path doesn't start with /api/v1/ingest
- Key shape:
sv0_prod_*orsv0_staging_*. SHA256 hashed at rest. - Path-prefix guard: any request that doesn't start with
/api/v1/ingestreturns 403INSUFFICIENT_SCOPE. - Tenant: embedded in the key record, not in any header.
- Legacy
X-Api-Keyform: the bearer middleware also acceptsX-Api-Key: sv0_*headers — this is the format sv0-connectors' PlatformClient still uses. Both forms route through the sameverifyApiKey()path. Once sv0-connectors migrates toAuthorization: Bearer, the legacy header path can be deprecated.
Files:
src/domain/connector-instance-api-keys/— provisioning, rotation, hashingsrc/api/middleware/bearer-token-middleware.ts:300+—verifyApiKey()+ path-prefix guardsrc/api/routes/admin/connector-api-keys.ts— admin CRUD route (super-admin gated, browser-cookie only)
Layer 1 — Cloudflare Access (network perimeter)
| Origin | CF Access protection |
|---|---|
app.securityv0.com (prod) | None. Open to anyone at the network layer. Application auth (WorkOS hosted login + 401 on protected routes) is the only gate. deploy-prod.yml still sends CF-Access-* headers conditionally; Cloudflare ignores them. |
dev.securityv0.com (dev) | Yes. "SecurityV0 Dev" Access app. Browsers: hosted login (email OTP / GitHub org). CI: service-token headers. |
pr-N-dev.securityv0.com (PR previews) | Yes. Separate "SecurityV0 PR Previews" Access app. Same shape — hosted login or service tokens. |
pr-N.sv0-reviews.pages.dev (visual review HTML) | None (intentionally — public Pages site). The data the reports render is captured behind CF Access by the visual-review workflow holding CF_ACCESS_*_VISUAL tokens. |
Service tokens: for CI to bypass the hosted-login redirect on dev / PR-preview origins, set the headers CF-Access-Client-Id + CF-Access-Client-Secret. Two distinct tokens exist:
CF_ACCESS_CLIENT_ID/SECRET_DEPLOY— used by deploy + smoke tests + integration tests.CF_ACCESS_CLIENT_ID/SECRET_VISUAL— used by visual-review's headless Playwright.
Prod has no CF Access app. Any agent or CI calling app.securityv0.com only needs the WorkOS bearer (or session cookie); CF Access headers are ignored if sent.
See Cloudflare Zero Trust Access for the full configuration.
Layer 2 — application auth (WorkOS bearer + session cookie)
Already covered above. The relevant invariants:
- Cookie path:
sv0_sessionsealed withSESSION_COOKIE_PASSWORD(iron-session). Set on/auth/callback. Read on every subsequent request bycreateSessionMiddleware. - Bearer path: dispatches on credential shape (
user_*JWT /client_*JWT /sv0_*API key). Tenant comes from the credential, never from a header. - No legacy auth path exists. Tenant identity on bearer-authenticated requests comes from the credential (
org_idclaim or API-key record), never from a header. The IDOR class (#347) is closed by construction — no production code path can reach areq.authpopulated from an unauthenticated header.
Layer 3 — tenant + scope intersection
createTenantMiddleware resolves req.authContext.tenant:
- Cookie path: validates the URL slug (
/t/<slug>) against the user's memberships in the local mirror. - Bearer path: looks up
findTenantByProviderOrgId(jwt.org_id).
createMembershipMiddleware then resolves effective permissions:
- Human cookie: full role-derived permissions, plus super-admin if
user.is_super_admin. delegated_agent(device_code): permissions are intersected with the agent client's declared scopes, then production env_policy strips writes by default.machine(client_*JWT): scopes from the JWT alone — no user-perm intersection.api_key(sv0_*): hardcodedconnector:ingestscope — see the path-prefix guard.
File: src/api/middleware/auth-middleware.ts — both createTenantMiddleware and createMembershipMiddleware.
Where to go next
| If you need… | Go to |
|---|---|
| "I'm an agent — how do I auth?" the one-page lookup | Agent and M2M Authentication |
| The architectural reference (principal-kind taxonomy, identity lifecycle, lookup tables) | Architecture: 13 — Authentication and User Management |
| WorkOS dashboard configuration (orgs, OAuth clients, DNS, rotation) | WorkOS Production Configuration |
| CF Access service-token provisioning + the perimeter design | Cloudflare Zero Trust Access |
| Which GitHub secret feeds which workflow | GitHub Secrets Inventory |
| Why auth has the single-signal shape it does | Auth Simplification Plan (2026-05-08) |
Staff CLI setup (npm run auth:login) | scripts/cli/README.md in sv0-platform |