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.


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, M2M) go through a per-ConnectorInstance server-issued API key, sent as the X-Api-Key header alongside the CF Access service-token headers. This path is tracked in sv0-platform#645; until it ships, WorkOSAuthProvider.verifyApiKey is stubbed and ingest is blocked even though browser auth works.

WorkOS org-scope M2M Connect Apps are explicitly deferred to future customer/partner-managed runners. The decision and rationale are in phase-b-prod-cutover.md (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).


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.