Skip to main content

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 thisJWT/credential shapeWhere it's documented
A SecurityV0 staff member running a CLI from your laptopStaff 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 tokensCI M2M Connect App (STAGING_CI_M2M_*)JWT, sub=client_*, anonymous service principal§13.2 of architecture/13
A connector worker ingesting normalized graph dataConnector 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 AuthKitOAuth authorization code → JWT§13.4 of architecture/13 — not yet wired
A human operator or staff super-admin in a browserAuthKit session cookieiron-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).

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_id claim. The middleware resolves this via findTenantByProviderOrgId(). x-tenant-id is ignored on bearer paths.
  • Connector API key: tenant is embedded in the key record (tenantId field at src/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:

PathAudit subjectAudit agentClientId
Staff CLI (device_code)provider_user_id (the human, e.g. user_01ABC…)claude-code registry entry's clientId
CI M2Mclient_id of the Connect App (e.g. ci-staging-m2m)n/a (service principal)
Connector API keythe connector's provider_user_id (synthetic) + connectorInstanceIdn/a (api_key provenance)
Cookie sessionprovider_user_id of the humann/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.