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
| Gate | What it checks | Headers / credential | Failure looks like |
|---|---|---|---|
| 1 — Cloudflare Access | Network perimeter — are you a permitted service token or Workspace user? | CF-Access-Client-Id, CF-Access-Client-Secret | 403 Forbidden from CF edge (no HTML body, or CF error page) |
| 2 — WorkOS session | App-layer identity — who are you inside sv0? | Authorization: Bearer <jwt> OR sv0_session cookie | 302 → /login (browser) or 401 Unauthorized (API) |
Which URLs each gate protects:
| URL | CF Access app | WorkOS 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:5173 | None | AUTH_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.tsreadssv0-platform/.envat startup and injectsCF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRETinto Playwright'sextraHTTPHeaders. No export orsource .envneeded. - Gate 2 (WorkOS):
mintAutomationCookie()POSTs to/api/v1/automation/browser-sessionswith your stored bearer JWT and injects the returnedsv0_sessioncookie into the Playwright context. The bearer comes from~/.config/sv0/auth.json(written bynpm 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.ts → fetchSv0() 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 adelegated_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.
Related docs
- §13.7 — SecurityV0 team bot access (interactive vs headless vs services)
- provision-personal-agent.md — tombstone for the removed personal-agent bridge (sv0-platform#826)
- phase-b-prod-cutover.md — M2M / API-key decision rationale (#645)
scripts/cli/README.md— device_code login, token file, troubleshooting.claude/rules/visual-review-tooling.md— visual-*.ts env vars, CF token auto-load- cf-access-service-token-setup.md — how CF Access service tokens are provisioned