Skip to main content

Agent Auth for Deployed Envs

TL;DR

Every deployed sv0 URL goes through two gates in series: Cloudflare Access (service token headers) and a WorkOS session (bearer JWT or browser cookie). For visual scripts, both are handled automatically — just run the script. For ad-hoc API calls, get a bearer with npm run auth:login and pass it with both CF headers.

This is about reaching a VM's URL, not its shell. The CF service token here is HTTP-only — it cannot mint an SSH cert to dev-azure-ssh.securityv0.com. For browserless ops on a dev/staging VM's shell, use Tailscale SSH — see ADR-023 §3.4.6.


The two gates

GateWhat it checksHeaders / credentialFailure looks like
1 — Cloudflare AccessNetwork perimeter — are you a permitted service token or Workspace user?CF-Access-Client-Id, CF-Access-Client-Secret403 Forbidden from CF edge (no HTML body, or CF error page)
2 — WorkOS sessionApp-layer identity — who are you inside sv0?Authorization: Bearer <jwt> OR sv0_session cookie302 → /login (browser) or 401 Unauthorized (API)

Which URLs each gate protects:

URLCF Access appWorkOS provider
app.securityv0.com"SecurityV0 Production"AUTH_PROVIDER=workos
dev.securityv0.com"SecurityV0 Dev"AUTH_PROVIDER=workos
pr-N-dev.securityv0.com"SecurityV0 PR Previews"AUTH_PROVIDER=workos
localhost:8080 / localhost:5173NoneAUTH_PROVIDER=dev (synthetic bypass)

Both gates must be crossed. Gate 1 is crossed by injecting CF service-token headers. Gate 2 is crossed by one of the four recipes below. For the principal-kind taxonomy (why delegated_agent vs service), see §13.7.


Recipe 1 — Visual scripts (most common)

Use this when: running visual-screenshot.ts, visual-qa.ts, or visual-diff-report.ts against any deployed env.

Both gates are handled automatically:

  • Gate 1 (CF Access): load-env.ts reads sv0-platform/.env at startup and injects CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET into Playwright's extraHTTPHeaders. No export or source .env needed.
  • Gate 2 (WorkOS): mintAutomationCookie() POSTs to /api/v1/automation/browser-sessions with your stored bearer JWT and injects the returned sv0_session cookie into the Playwright context. The bearer comes from ~/.config/sv0/auth.json (written by npm run auth:login).
# Run once on this machine if you haven't already:
npm run auth:login

# Then just run the script — both gates handled automatically:
QA_BASE_URL=https://dev.securityv0.com npx tsx scripts/visual-screenshot.ts
QA_BASE_URL=https://pr-42-dev.securityv0.com npx tsx scripts/visual-screenshot.ts

For full env-var reference and --agent-report usage, see .claude/rules/visual-review-tooling.md (in sv0-platform).

Cookie TTL: the sv0_session cookie is valid for 30 minutes. For runs longer than 30 minutes, re-mint by running the script again.


Recipe 2 — Claude Code from a worktree, ad-hoc API calls

Use this when: you need to curl an API endpoint from a worktree or call fetchSv0 from a script, not via a visual-* script.

Step 1 — Get a bearer JWT (one-time setup per machine)

cd /path/to/sv0-platform
npm run auth:login # opens browser, writes ~/.config/sv0/auth.json
npm run auth:status # verify: should print "valid" with your email

Credentials live in .env under STAGING_WORKOS_APP_CLAUDECODE_CLIENT_ID / _SECRET. See scripts/cli/README.md for 1Password location and full troubleshooting.

Footgun: the device_code flow opens a browser tab. On headless or remote machines xdg-open may silently fail — copy the URL printed to stderr manually. Use Recipe 3 for fully headless environments.

Step 2 — Call the API with both gates

The bearer JWT is sufficient for API endpoints (no cookie needed). CF Access headers come from .env:

# Read your stored bearer token
TOKEN=$(jq -r .access_token ~/.config/sv0/auth.json)

# Read CF tokens from .env (or export them from your shell)
source /path/to/sv0-platform/.env

curl -sS \
-H "Authorization: Bearer ${TOKEN}" \
-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
-H "X-Tenant-Id: demo-tenant" \
https://dev.securityv0.com/api/v1/posture/summary

Scripts using api-client.tsfetchSv0() handle token load and auto-refresh automatically.


Recipe 3 — Headless agent (no browser available)

Use this when: the agent runs unattended — remote Claude Code session, GitHub Actions sandbox, scheduled job, Telegram bot. Interactive browser auth (npm run auth:login) is not possible.

Status — 2026-05-07. The client_credentials "personal-agent bridge" that previously lived here was removed in sv0-platform#826. There is no longer a delegated_agent (staff-on-behalf-of-person) flow without a browser.

The right answer depends on who the agent represents:

3a — Agent represents a person (you want delegated_agent attribution)

There is no fully-headless variant any more. Use Recipe 2: run npm run auth:login once on a machine that has a browser; the device_code refresh_token persists in ~/.config/sv0/auth.json and api-client.ts auto-refreshes on access_token expiry. Re-run auth:login when the refresh_token itself expires.

CI cannot run as a named staff member through this path — the device_code grant requires interactive consent.

3b — Agent represents a service / connector (you want service attribution)

Service-to-platform calls (connector ingest, scheduled scans) authenticate with a per-ConnectorInstance server-issued API key (sv0_<env>_<hex>), sent as the X-Api-Key header alongside the CF Access service-token headers. Authorization: Bearer <sv0_…> is also accepted and takes precedence when both headers are present.

This shipped in sv0-platform#645 (merged 2026-04-30) — WorkOSAuthProvider.verifyApiKey is live, not stubbed. It SHA-256-hashes the key and looks it up in the connector_instance_api_keys collection (src/api/auth/providers/workos-provider.ts, via findConnectorApiKeyByHash), returning the key's bound tenant_id + connector_instance_id and the connector:ingest scope. API-key auth is allowed only on the /api/v1/ingest and /api/v1/syncs path prefixes.

Minting: keys are issued per environment via the super-admin route POST /api/v1/admin/connector-instances/<instanceId>/api-keys. That route requires an interactive super-admin browser session — it rejects delegated_agent tokens — and returns the plaintext once at mint time (only the SHA-256 hash is persisted). A connector's .env carries that plaintext as PLATFORM_API_KEY.

Reading the 401: the key is matched by a hash lookup scoped to one deployment's database, so a 401 INVALID_BEARER_TOKEN on ingest means the key is not valid in that environment's DB — revoked, never minted there, or minted against a different deployment. A key minted on dev will not work against dev-azure (separate database). It does not mean the path is stubbed. Fix: re-mint against the target environment and update PLATFORM_API_KEY.

⚠ WHICH host is which env — the mix-up that costs hours. dev.securityv0.com is the Hetzner host 178.156.217.150 (containers sv0-main-*, DB sv0_platform). It is NOT vm-sv0-dev-1 — that VM is dev-azure (DB sv0_dev). Minting/creating against vm-sv0-dev-1 and then ingesting to dev.securityv0.com = guaranteed 401. The full env→host→Mongo map, plus how to create a tenant and mint a connector key non-interactively (direct Mongo insert, the agent path), is in create-tenant-and-connector-on-deployed-env. Prod (app.securityv0.com) is 178.156.245.75 / containers sv0-platform-* — same DB name sv0_platform, so the container-name prefix is the ONLY safeguard: sv0-platform-* = PROD, do not touch.

WorkOS org-scope M2M Connect Apps are explicitly deferred to future customer/partner-managed runners. The decision and rationale are in sv0-platform#645 (decision locked 2026-04-29).


Recipe 4 — Ad-hoc curl from a fresh shell (no script helpers)

Use this when: you need a one-off curl from any shell without loading api-client.ts or load-env.ts.

# A. Source CF tokens from .env
source /path/to/sv0-platform/.env
# CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET are now in scope

# B. Get bearer from stored auth (device_code path, requires prior npm run auth:login)
TOKEN=$(jq -r .access_token ~/.config/sv0/auth.json)

# C. Make the call
curl -sS \
-H "Authorization: Bearer ${TOKEN}" \
-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
-H "X-Tenant-Id: demo-tenant" \
https://dev.securityv0.com/api/v1/posture/summary

Minting a cookie manually (for Playwright or browser automation without script helpers):

curl -sS -X POST \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}" \
-D - \
https://dev.securityv0.com/api/v1/automation/browser-sessions

Note: if hitting a PR preview (pr-N-dev.securityv0.com) that was deployed before PR #728 landed, the /api/v1/automation/browser-sessions endpoint returns 404. The API is still reachable with just the bearer (no cookie needed for non-browser API calls).


Recipe 5 — End-to-end connector ingest to a deployed env

Use this when: you need to refresh a tenant's graph on a deployed env (e.g. fix a demo tenant's Exposures page) by running a connector and submitting its NormalizedGraph. Recipe 3b covers the auth; this is the full scan → mint → submit → verify loop, with the two gotchas that bite every time.

Per-env reference

EnvApp URLSSH (Tailscale, no browser)Mongo DBAPI on VM
dev-azuredev-azure.securityv0.comssh sv0admin@vm-sv0-dev-1sv0_dev (local sv0-mongo)127.0.0.1:3000
stagingstaging.securityv0.comssh sv0admin@vm-sv0-staging-1sv0_staging (Atlas Flex — no local mongo container)127.0.0.1:3000
locallocalhost:3000n/asv0_platform (Colima Docker)n/a

VM shells are Tailscale now. ssh sv0admin@vm-sv0-dev-1 from any tailnet device — no cloudflared access login, no CF prompt (ADR-023 §3.4.6). The CF-Access-SSH path is the fallback. Localhost on the VM bypasses CF Accesscurl http://127.0.0.1:3000/... only hits the app api-key gate.

A. Scan (read-only, from sv0-connectors)

cd integrations/entra-servicenow      # source creds live in this connector's .env
.venv/bin/entra-servicenow --all --graph-json /tmp/graph.json # no --submit = read-only

Inspect /tmp/graph.json before submitting (tenantId, connectorId, node count, and that excluded assets are absent). The connector .env typically targets a different tenant/URL (contoso / dev), so set the real tenantId in the file (or via flags) before submitting to another env.

B. Mint a key on the target VM — ⚠ MONGODB_DB footgun

Keys are per-env: a dev key 401s on dev-azure (separate DBs, see Recipe 3b). Mint directly into the target DB via ssh sv0admin@vm-sv0-dev-1. The doc shape is ConnectorInstanceApiKeyDoc (connector_instance_id, tenant_id (slug), key_hash = SHA-256 of plaintext, key_prefix, created_at, revoked_at:null). scripts/ci-mint-connector-key.ts does this — but it defaults MONGODB_DB=sv0_platform; you MUST override to sv0_dev / sv0_staging or the key lands in a DB the API never reads → silent 401 on every ingest.

C. Submit (localhost on the VM — no CF Access needed)

scp /tmp/graph.json sv0admin@vm-sv0-dev-1:/tmp/graph.json
ssh sv0admin@vm-sv0-dev-1 'curl -s -w "\n%{http_code}\n" \
-X POST http://127.0.0.1:3000/api/v1/ingest/normalized-graph \
-H "X-Api-Key: sv0_dev_<plaintext>" -H "Content-Type: application/json" \
--data-binary @/tmp/graph.json' # expect HTTP 202 {data:{status:"accepted"}}

⚠ Deletion detection defaults ON — a missing scanScope means full

This is the trap, and it's the opposite of "additive by default." Sync mode is derived from the payload: only an explicit scanScope.mode === "incremental" is additive — anything else, including a payload with NO scanScope at all, is treated as full (sv0-platform/src/workers/handlers/sync-ingestion.ts, syncMode derivation). full releases every existing entity (scoped per payload connectorId × source_system, keyed source_system::source_id) that's absent from the new graph. The entra-servicenow connector emits no scanScope, so a naive re-submit into a populated tenant runs with deletions ON — this is exactly how a partial re-ingest amputated Contoso in 2026-07.

  • The circuit breaker computes one aggregate deletion ratio per connector per sync, across all its in-scope source systems combined — >50% overall trips it (stricter per-entity-type thresholds apply: identity 30%; workload/role/permission 40%) and blocks all deletions for that sync. An amputation below every threshold goes through silently, and a full wipe of a single source system can hide under the combined ratio when the connector spans several. Do not rely on the breaker.
  • Conversely, an explicit incremental submit never removes entities — so re-submitting a clean graph won't delete the stale rows you wanted gone, and re-materialization can inflate findings while they linger.
  • Post-ADR-033 (sv0-platform#1864): a tenant with deletion_policy: "disabled" refuses deletion detection server-side regardless of payload, its protected_entities floor is never deleted at any ratio, and a deliberate cleanup uses a time-bounded single-use maintenance override — prefer that over relying on payload discipline. See ADR-033 (lands with sv0-documentation#430) and the PDI rebuild runbook.
  • To remove stale entities, the sanctioned path is a complete clean re-submit in (default) full mode — deletion detection releases the absent entities through the pipeline (entity versions, events, findings re-evaluation), and on a deletion_policy: "disabled" tenant that requires the maintenance override first. Only as a last resort, on an env without ADR-033 protections and where a full re-submit is too risky, mirror the platform's own soft-delete directly in Mongo: set properties.status:"deleted" + deleted_at + removed_by_sync_id, $pull the connector from connector_owners[], expire open entity_versions, and set dependent eval: findings to status:"false_positive". Snapshot the docs first — this is the only rollback. Never rewrite source_ids in Mongo (_id is SHA-derived; reference drift). See cross-env tenant reconciliation.

D. Verify

ssh sv0admin@vm-sv0-dev-1 'sudo docker exec sv0-mongo mongosh sv0_dev --quiet --eval "
print(db.entities.countDocuments({tenant_id:\"<tenant>\",\"properties.status\":{\$ne:\"deleted\"}}));
"'

Then eyeball /t/<tenant>/exposures in the UI.


When recipes fail — diagnostic flow

Got 403 (before any HTML)?
→ CF Access gate: CF headers missing, wrong, or token not in the Access policy.
Fix: verify CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET in .env match
the service token in the Cloudflare Zero Trust dashboard.
Ref: docs/infrastructure/cf-access-service-token-setup.md

Got 302 → /login (or browser lands on login page)?
→ WorkOS gate not crossed: no sv0_session cookie and no bearer header.
Fix: add Authorization: Bearer <token> header, or mint a cookie via
POST /api/v1/automation/browser-sessions.

Got 401 from API?
→ Cookie expired (30-min TTL) OR bearer expired (5-min access_token TTL).
Fix: re-run npm run auth:login (or re-mint client_credentials token).
Verify: npm run auth:status — it auto-refreshes if the refresh_token is valid.


Human login on PR previews — redirect host errors

If you see 500: Disallowed redirect host: <host> after clicking login, the deployed instance has WORKOS_REDIRECT_URI_ALLOWED_HOSTS set but the request host isn't in the allowlist — add it on the dev/prod environment Secret. This fix is now live (#813 merged).


Why dev-bypass no longer works on PR previews

Before PR #742/#778, PR preview instances ran AUTH_PROVIDER=dev, which accepted the synthetic /auth/callback?code=dev-bypass shortcut used by CI visual-review scripts. After those PRs, all deployed instances (including PR previews) run AUTH_PROVIDER=workos.

The dev-bypass shortcut only works when AUTH_PROVIDER=dev is set — which is localhost / npm run dev only. On all deployed envs, the WorkOS session must be obtained through one of the four recipes above.

For the full principal-kind model and the rationale, see §13.7 of the auth architecture doc.