Access Protection for SecurityV0 Environments
Cloudflare Access protects the non-prod origins from public exposure. Prod (app.securityv0.com) is open at the network layer — the WorkOS hosted login is the only gate. Authentication, end-to-end is the canonical "which origin needs which credential" reference.
Current state
| Origin | CF Access protection | Browser access | Programmatic / CI access |
|---|---|---|---|
app.securityv0.com (prod) | None | WorkOS hosted login (application layer) | WorkOS bearer JWT only — no CF service-token headers needed |
dev.securityv0.com (dev) | "SecurityV0 Dev" Access app | Email OTP / GitHub org via CF, then WorkOS hosted login | CF-Access-Client-Id + CF-Access-Client-Secret (*_DEPLOY or *_VISUAL) + WorkOS bearer JWT |
pr-N-dev.securityv0.com (PR previews) | "SecurityV0 PR Previews" Access app (separate from Dev) | Same as Dev | Same as Dev |
pr-N.sv0-reviews.pages.dev (visual review reports) | None (intentionally — public Pages) | Open | n/a — agents read visual-report.md / .json from the workflow run artifact, not this URL |
WorkOS-backed application auth is the canonical identity gate. The 401 returned for any unauthenticated request to a protected route is sufficient — adding CF Access in front of that produced two login surfaces (CF Access first, WorkOS second) without strengthening the security model. Dev and PR previews stay behind CF Access to keep in-flight builds and unstable data off the public internet.
Identity providers
Two providers configured in Cloudflare Zero Trust:
| Provider | Who It's For | Why |
|---|---|---|
| One-Time PIN (OTP) via Email | Team members with @securityv0.com email | Zero setup — Cloudflare sends a code to the email. |
| GitHub OAuth | Team members with GitHub accounts in the securityv0 org | Natural for developers; org-based policy. |
Email OTP is the primary method; GitHub is a convenience layer for developers already logged in.
Access applications
| Application | Domain(s) | Policy |
|---|---|---|
| SecurityV0 Dev | dev.securityv0.com, *.securityv0.com (single-label wildcard for PR previews) | Allow: emails ending in @securityv0.com OR GitHub org securityv0 |
| SecurityV0 PR Previews | *.securityv0.com (PR-preview hostnames specifically) | Allow: emails ending in @securityv0.com OR GitHub org securityv0 |
Single-label wildcard *.securityv0.com is the only depth Cloudflare's free Universal SSL certificate covers — depth-2 patterns like *.dev.securityv0.com silently break in browsers. PR-preview hostnames follow pr-N-dev.securityv0.com to fit under the depth-1 wildcard.
Bootstrap. The PR Previews app is bootstrapped via the bootstrap-cf-access GitHub workflow (sv0-platform#575). Run with mode = dry-run to preview changes, apply to reconcile.
Pre-existing Access Applications (unrelated to platform but worth noting):
sv0-reviews.pages.dev— visual review reports (GitHub org policy)*.sv0-docs.pages.dev— documentation site (GitHub org policy)
Policy definition
Rule: Allow
Include:
- Email domain: securityv0.com (covers OTP login)
- GitHub organization: securityv0 (covers GitHub OAuth)
Rule: Service Auth (non_identity bypass)
Include:
- Service Token: "ci-deploy-bot"
- Service Token: "visual-review-bot"
Programmatic / bot access
CI/CD and automation against non-prod URLs send CF-Access-Client-Id + CF-Access-Client-Secret headers to bypass the login page. Prod is open at the network layer and ignores any CF headers.
Access patterns
| Bot / Workflow | Mechanism |
|---|---|
| deploy-dev.yml | SSH goes direct to server IP (no CF). External health checks against dev.securityv0.com use *_DEPLOY service token headers. |
| deploy-prod.yml | SSH direct to server IP. app.securityv0.com has no CF Access app — no headers needed. |
| visual-review.yml (local mode) | Screenshots localhost:8080 on the runner. No CF traffic. |
| visual-review.yml (env mode) | Playwright sends *_VISUAL headers on every request to live URLs. |
| seed-demo-w1.ts (remote) | Adds CF headers to fetch calls when targeting live URLs. |
| Connectors (sv0-connectors) | Optional CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET env vars on the connector CLI. |
| Caddy health probes, MongoDB | Internal traffic only, not exposed through Cloudflare. |
Service tokens
Two service tokens, kept separate so they can be rotated independently:
ci-deploy-bot— used by GitHub Actions deploy workflowsvisual-review-bot— used by Playwright-based visual capture
Stored in GitHub Actions secrets:
CF_ACCESS_CLIENT_ID_DEPLOY=<token-id>
CF_ACCESS_CLIENT_SECRET_DEPLOY=<token-secret>
CF_ACCESS_CLIENT_ID_VISUAL=<token-id>
CF_ACCESS_CLIENT_SECRET_VISUAL=<token-secret>
See the GitHub Secrets Inventory for the full list of CF-related secrets.
Custom-branded login page
Cloudflare Access supports full login page customization under Zero Trust → Settings → Custom Pages.
| Element | Value |
|---|---|
| Logo | favicon.svg (shield icon with red→magenta gradient) |
| Background color | #f4f2f1 (SecurityV0 primary background) |
| Header text color | #111111 (primary text) |
| Button color | #8a43e1 (brand purple) or gradient #ff2f2f → #d511fd |
| Font | Inter Display (or system default if custom fonts not supported) |
| Header text | "SecurityV0" |
| Description | "Sign in to access the SecurityV0 platform." |
Block page (unauthorized users)
When someone authenticates but isn't in the allowed policy (e.g., non-securityv0.com email), they see the block page:
You don't currently have access to SecurityV0.
If you'd like to schedule a demo or request access,
please contact us at contact@securityv0.com
or visit https://securityv0.com/contact
The block page supports custom HTML, so contact links / mailto / external form embeds are possible.
Note: Adding "Want access?" text on the login page itself (rather than the block page) requires a Cloudflare Workers custom page or the Enterprise plan. On Free/Pro, the description field below the IdP buttons supports basic text only.
Session configuration
| Setting | Value | Rationale |
|---|---|---|
| Session duration | 24 hours | Re-auth once per day, low friction for team |
| Cookie domain | .securityv0.com | Covers all subdomains |
| WARP requirement | Off | Not needed at current team size |
| Purpose justification | Off | Not needed for team access |
Diagnosing 401: perimeter vs app-layer
A 401 from dev.securityv0.com or pr-N-dev.securityv0.com can come from two layers. Distinguishing them by response body shape prevents wild-goose chases (e.g. blaming stale CF tokens when the app rejected the request).
App-layer 401 (Express auth middleware)
The CF Access perimeter passed; the application rejected.
HTTP/2 401
content-type: application/json
…
{"error":{"code":"UNAUTHORIZED","message":"Authentication required","status":401}}
Other app-layer codes you may see: MISSING_API_KEY, INVALID_API_KEY, ADMIN_REQUIRES_INTERACTIVE_SESSION (for delegated_agent hitting admin routes). The common signal is JSON body with an error.code.
Likely fixes (in order):
- Missing or expired WorkOS bearer JWT — re-run
npm run auth:login(or refreshSV0_API_TOKENin CI). - Missing connector API key —
Authorization: ApiKey sv0_<env>_<64hex>header. - Missing tenant header —
x-tenant-id: <slug>is mandatory on all/api/v1/*reads. delegated_agentsession hitting an admin-only route — use a cookie session instead, or insert directly via Mongo (see thereference_connector_api_key_direct_mongo_insertmemory).
CF Access perimeter 401 / 302
The application never saw the request.
HTTP/2 302
location: https://securityv0.cloudflareaccess.com/cdn-cgi/access/login/...
Or:
HTTP/2 401
content-type: text/html
…
<!doctype html>… <a href="https://securityv0.cloudflareaccess.com/…">…
The common signal is non-JSON body or a Location: header pointing at *.cloudflareaccess.com.
Likely fixes (in order):
- Missing
cf-access-client-id/cf-access-client-secretheaders — load fromsv0-platform/.env. - Stale or revoked service token — Ivan re-pulls from 1Password vault
SecurityV0itemsCloudflare Access — visual-review-bot/Cloudflare Access — ci-deploy-bot(seecf-access-service-token-setup.md). - Hitting the wrong CF Access app (e.g. dev token against the PR-preview app).
Diagnostic recipe
set -a; . "$HOME/dev/securityv0/repos/sv0-platform/.env"; set +a
# Always -i so headers are visible. Inspect at least 200 bytes of body.
curl -i \
-H "cf-access-client-id: $CF_ACCESS_CLIENT_ID" \
-H "cf-access-client-secret: $CF_ACCESS_CLIENT_SECRET" \
https://dev.securityv0.com/api/v1/<endpoint>
If the first character of the body is { → app layer. If you see < or a 302 to *.cloudflareaccess.com → perimeter.
Cross-references:
sv0-skills/.claude/skills/sv0-access/SKILL.md"Common gotchas" table includes this row. Memory:feedback_read_401_body_before_blaming_cf_tokens.
Cost
| Item | Cost | Notes |
|---|---|---|
| Cloudflare Zero Trust (Free) | $0/month | Up to 50 users |
| GitHub OAuth App | $0 | Free to create |
| Service Tokens | $0 | Included in Zero Trust |
| Custom branding | $0 | Available on free tier |
Total: $0/month.
Risks and mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Service token leaked in logs | Bot access compromised | Mask secrets in CI, rotate tokens quarterly |
| Cloudflare outage blocks dev access | Dev team locked out of dev URLs | SSH direct to servers is the emergency backdoor |
| OTP emails delayed | Login friction | GitHub OAuth as fallback IdP |
| Wildcard policy misconfigured | PR previews unprotected | Test with pr-test-dev.securityv0.com before going live |
| Service token connectivity fails silently | Visual review / deploy passes without real auth | Validate response body (JSON status=ok), not just HTTP status code |
Decision log
| Decision | Chosen | Alternatives Considered | Rationale |
|---|---|---|---|
| Perimeter auth provider | Cloudflare Access | Authelia, Authentik, Tailscale Funnel, Basic Auth | Already on Cloudflare, zero cost, no self-hosting, branded login pages, Service Tokens for bots |
| Primary IdP | Email OTP | Google Workspace, Okta | Zero setup cost, works immediately, no external IdP dependency |
| Secondary IdP | GitHub OAuth | None | Natural for dev team, org-based policy, free |
| Bot access | Service Tokens | IP allowlist, WARP connector, mTLS | Simplest to implement, works across all environments, no network config changes |
| Session duration | 24 hours | 1 hour, 7 days | Balance between security and convenience |
| Prod perimeter | WorkOS only (no CF Access) | CF Access + WorkOS (defense in depth) | Two login surfaces without a stronger security model; WorkOS application auth is the canonical identity gate |