Skip to main content

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…CredentialWhere the identity comes from
A human in a browsersv0_session iron-session cookieWorkOS hosted login → /auth/callback mints the cookie
An agent or serviceAuthorization: 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 both authProvider and storageAdapter are wired. This means production cannot fall back to the test-only legacy path that lacks bearer/session middleware.


  1. User hits app.securityv0.com/login (prod is open at the network layer; dev is behind CF Access — see Layer 1 below).
  2. /login redirects to WorkOS AuthKit hosted login. The redirect_uri param is derived per-request from the request host and validated against WORKOS_REDIRECT_URI_ALLOWED_HOSTS (single-host prod is a one-element list; dev/PR-previews use *.securityv0.com).
  3. User authenticates (Google/email/etc.) at WorkOS. WorkOS redirects to /auth/callback?code=….
  4. /auth/callback calls authenticateWithCode, then listOrganizationMemberships(userId) to compute super-admin (is_super_admin = memberships.some(m => m.organizationId === WORKOS_SUPER_ADMIN_ORG_ID)).
  5. The route upserts the user in the local users mirror with the canonical is_super_admin value, creates an sv0_session cookie sealed with SESSION_COOKIE_PASSWORD, and redirects to the requested return_to.
  6. Subsequent requests carry the cookie. createSessionMiddleware unseals it and reads req.authContext.user.is_super_admin directly 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 new is_super_admin: false to the DB row), or an explicit POST /auth/logout (which sets sessions_revoked_at and 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/login and /auth/callback
  • src/api/auth/providers/workos-provider.tshandleCallback, getOrganizationMemberships (public AuthProvider method; 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_MS
  • src/api/middleware/auth-middleware.tscreateSessionMiddleware

Failure mode. A WorkOS API error during the membership lookup degrades the user to non-super-admin for that login — they see /no-access until 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-code agent client cannot do writes regardless of who owns it.

Files:

  • scripts/cli/auth.ts — laptop-side device_code flow
  • src/api/auth/agent-clients.ts — registry (claude-code is the only entry today)
  • src/api/middleware/bearer-token-middleware.tsattachDelegatedAgentContext
  • src/api/auth/providers/workos-provider.tsresolveAgentClientId (introspection)
  • src/api/middleware/auth-middleware.tsapplyDelegatedAgentScopes in createMembershipMiddleware

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. No principalUserId field. Pure service principal — no human attribution.
  • Token attribution: the Connect App itself.
  • No introspectionclient_* tokens are self-attributing via sub and don't go through applyDelegatedAgentScopes. Scopes on the token are honored as-is.

Files:

  • .github/workflows/visual-review.yml, pr-preview-admin.yml, deploy-dev.yml (the seed step) — inline curl mints
  • src/api/middleware/bearer-token-middleware.tsattachMachineContext

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_* or sv0_staging_*. SHA256 hashed at rest.
  • Path-prefix guard: any request that doesn't start with /api/v1/ingest returns 403 INSUFFICIENT_SCOPE.
  • Tenant: embedded in the key record, not in any header.
  • Legacy X-Api-Key form: the bearer middleware also accepts X-Api-Key: sv0_* headers — this is the format sv0-connectors' PlatformClient still uses. Both forms route through the same verifyApiKey() path. Once sv0-connectors migrates to Authorization: Bearer, the legacy header path can be deprecated.

Files:

  • src/domain/connector-instance-api-keys/ — provisioning, rotation, hashing
  • src/api/middleware/bearer-token-middleware.ts:300+verifyApiKey() + path-prefix guard
  • src/api/routes/admin/connector-api-keys.ts — admin CRUD route (super-admin gated, browser-cookie only)

Layer 1 — Cloudflare Access (network perimeter)

OriginCF 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.


Already covered above. The relevant invariants:

  • Cookie path: sv0_session sealed with SESSION_COOKIE_PASSWORD (iron-session). Set on /auth/callback. Read on every subsequent request by createSessionMiddleware.
  • 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_id claim or API-key record), never from a header. The IDOR class (#347) is closed by construction — no production code path can reach a req.auth populated 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_*): hardcoded connector:ingest scope — 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 lookupAgent 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 designCloudflare Zero Trust Access
Which GitHub secret feeds which workflowGitHub Secrets Inventory
Why auth has the single-signal shape it doesAuth Simplification Plan (2026-05-08)
Staff CLI setup (npm run auth:login)scripts/cli/README.md in sv0-platform