Agent and M2M Authentication
Read this first if you are an agent, CI job, connector, or external script that needs to call the sv0-platform API. It is the canonical quick-reference. For full architectural context see 13 — Authentication and User Management.
TL;DR — pick the right path
| You are… | Use this | JWT/credential shape | Where it's documented |
|---|---|---|---|
| A SecurityV0 staff member running a CLI from your laptop | Staff CLI device_code flow (npm run auth:login) | JWT, sub=user_*, attributed to you | §13.7 of architecture/13 |
| A CI runner (visual-review, deploy-seed) needing M2M tokens | CI M2M Connect App (STAGING_CI_M2M_*) | JWT, sub=client_*, anonymous service principal | §13.2 of architecture/13 |
| A connector worker ingesting normalized graph data | Connector API key (sv0_*-prefixed) | Long-lived bearer key, scope-limited to /api/v1/ingest | §13.5 of architecture/13 |
| A customer's MCP client / AI agent (FUTURE) | Customer OAuth via AuthKit | OAuth authorization code → JWT | §13.4 of architecture/13 — not yet wired |
| A human operator or staff super-admin in a browser | AuthKit session cookie | iron-session sv0_session cookie | §4 of architecture/13 |
What you must NOT do
Important contrast: service-principal Connect Apps (attributed to the App itself, e.g. ci-staging-m2m) are fine and intended — see Path 2. What's forbidden is the identity-bridging pattern that promotes a client_credentials token to a delegated_agent context for a named human user.
1. Do NOT add a personal-agent bridge
There is no shipped path that promotes a client_credentials token to a delegated_agent context for a named human. The previous principalUserId field on AgentClientEntry and the personal-agent-ivan Connect App were removed; zero non-interactive consumers ever materialized.
The right answer depends on what you actually need:
- Headless staff automation that should be attributed to a specific human: no shipped clean path. Talk to the auth owner before building one.
- Headless service automation (CI, scheduled jobs, infrastructure): use a service-principal Connect App. Tokens are attributed to the App, not a human. See §13.2 of the architecture doc.
- A customer's AI agent or MCP client: use the customer-OAuth path (FUTURE — §13.4). Not yet wired.
2. Do NOT add a new env var for "your" service principal
The STAGING_/PROD_ prefix on agent-client envs (STAGING_WORKOS_APP_CLAUDECODE_CLIENT_ID etc.) is a known duplication; don't extend it. Use the unprefixed shape (WORKOS_APP_<NAME>_CLIENT_ID/SECRET) for any new entry and rely on GitHub Environment scoping.
3. Do NOT invent a new super-admin allowlist
The canonical super-admin signal is "the user is an active member of the WorkOS org named by WORKOS_SUPER_ADMIN_ORG_ID" — derived from WorkOS via listOrganizationMemberships at the cookie callback and at bearer JIT-upsert, cached on the local user mirror. There is exactly one signal — no parallel allowlist, no email-domain fallback. Don't add a second one.
To grant or revoke staff super-admin: add or remove the user in the WorkOS AuthKit dashboard's org membership UI for the securityv0-internal org. No env-var change, no redeploy. The membership lookup picks it up on next login.
Security invariants — never OK to bypass
The above are guardrails against accretion. The below are deliberate, load-bearing security guards. Disregarding them re-opens known vulnerability classes (#347 C3, #347 C1).
Iron-session cookie blob as Bearer → rejected
bearer-token-middleware.ts explicitly rejects iron-session cookie blobs presented as Authorization: Bearer (returns 401 SESSION_BEARER_UNSUPPORTED). This closes a known IDOR class (#347 C3) where a valid session token plus an attacker-chosen x-tenant-id would grant super-admin in any tenant. Don't try to "fix" the rejection — it's deliberate.
Tenant from JWT/cookie, never from a header on bearer paths
On any bearer-authenticated request, the tenant is bound by the credential (org_id claim or API-key record), not by a header. x-tenant-id is ignored on bearer paths. Re-reading the header would be the IDOR class #347 C1 closed. The middleware refuses to do this on purpose.
Path 1 — Staff CLI (npm run auth:login)
For SecurityV0 staff running scripts from their own laptop. Uses WorkOS OAuth device_code grant.
# One-time setup
cd ~/dev/securityv0/repos/sv0-platform
npm run auth:login # Browser pops; sign in
npm run auth:status # Verify token, prints sub=user_<your-id>
# Token lives at ~/.config/sv0/auth.json
# Auto-refreshed by auth:status when near-expired
Scripts that hit deployed envs (dev.securityv0.com, app.securityv0.com) read the token via scripts/lib/api-client.ts. See scripts/cli/README.md for full setup.
The agent client registry (src/api/auth/agent-clients.ts) defines exactly one staff agent: claude-code — scopes ["api:read", "ui:session:create"], defaultProdReadOnly: true. Don't add personal entries — see the "what you must NOT do" section above.
Path 2 — CI M2M (visual-review, deploy seed)
For non-interactive CI flows that need to mint a real bearer token against staging WorkOS. Uses client_credentials grant.
# In a GitHub workflow, secrets STAGING_CI_M2M_CLIENT_ID/SECRET are scoped
# to the dev environment. Mint a token at job start:
TOKEN=$(curl -fsS -X POST https://vast-balcony-35-staging.authkit.app/oauth2/token \
-d "grant_type=client_credentials" \
-d "client_id=${STAGING_CI_M2M_CLIENT_ID}" \
-d "client_secret=${STAGING_CI_M2M_CLIENT_SECRET}" \
| jq -r .access_token)
# Forward as SV0_API_TOKEN; api-client.ts forwards as Authorization: Bearer
echo "SV0_API_TOKEN=$TOKEN" >> "$GITHUB_ENV"
The CI M2M Connect App is ci-staging-m2m (referenced as connect_app_01KQHNSDZYSSBFAZQZRJTADFT5). It is a service principal — no human attribution, no principalUserId. Tokens carry sub=client_* and authenticate as the App itself.
Existing wiring: .github/workflows/visual-review.yml (line ~184) and .github/workflows/deploy-dev.yml (the seed step). If you need a similar mint in another workflow, copy the existing pattern; do not add new secrets.
Path 3 — Connector API key (sv0_* prefix)
For connector workers ingesting normalized graph data into /api/v1/ingest/*. Long-lived API key, server-issued, scope-restricted to ingest paths.
The provisioning, rotation, and verification flow is in #645 and the connector instance API keys domain (src/domain/connector-instance-api-keys/). Keys are SHA256-hashed at rest; the bearer middleware looks them up via verifyApiKey() and then enforces the path-prefix guard at bearer-token-middleware.ts:307.
# Connectors send EITHER form (Authorization: Bearer is preferred):
curl -X POST https://api.securityv0.com/api/v1/ingest/normalized-graph \
-H "Authorization: Bearer sv0_prod_<key>" \
-H "Content-Type: application/json" \
-d @graph.json
# Legacy fallback (sv0-connectors PlatformClient still uses this):
curl -X POST … -H "X-Api-Key: sv0_prod_<key>" …
Connector keys carry scope connector:ingest and are rejected on any path that does not start with /api/v1/ingest — by design. Don't expand the path prefix list to widen connector access; if a new connector surface is needed, that is a separate scope-token decision.
Tenant resolution — DO NOT trust headers on bearer paths
Whichever path you use, the tenant is bound by the auth credential, not by a header:
- Bearer M2M JWT: tenant comes from the JWT's
org_idclaim. The middleware resolves this viafindTenantByProviderOrgId().x-tenant-idis ignored on bearer paths. - Connector API key: tenant is embedded in the key record (
tenantIdfield atsrc/api/auth/auth-provider.ts:62). Single lookup; no header needed. - Cookie session: tenant comes from the URL slug
/t/:tenantSlug/...(validated against the user's memberships).
Re-reading x-tenant-id on a bearer-authenticated request would be the IDOR class the audit at #347 closed. The middleware refuses to do this, deliberately.
Audit attribution
Every request logs the originating principal. For each path:
| Path | Audit subject | Audit agentClientId |
|---|---|---|
Staff CLI (device_code) | provider_user_id (the human, e.g. user_01ABC…) | claude-code registry entry's clientId |
| CI M2M | client_id of the Connect App (e.g. ci-staging-m2m) | n/a (service principal) |
| Connector API key | the connector's provider_user_id (synthetic) + connectorInstanceId | n/a (api_key provenance) |
| Cookie session | provider_user_id of the human | n/a |
If you need a new principal kind, see §13.1 of the architecture doc — it's a five-row table that locks the taxonomy.
Operational note — distinguishing real outages from real revocations
A WorkOS API outage degrades cookie logins (and first-time bearer JIT-upserts) to non-super-admin until WorkOS recovers; existing-row bearer requests are unaffected. Triage rule: if multiple staff are simultaneously bounced on cookie login and WorkOS status pages report errors, it's the outage; if it's one person on a healthy WorkOS, it's a real membership change. Full reasoning in architecture §16.2.
Related
- Architecture: 13 — Authentication and User Management — full reference
- Architecture §16: Design principles and operational notes — patterns to avoid + failure modes
- Auth Simplification Plan (2026-05-08) — rationale for the current single-signal shape
- WorkOS Production Configuration — operational truth
- sv0-platform: scripts/cli/README.md — CLI auth setup