Skip to main content

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

OriginCF Access protectionBrowser accessProgrammatic / CI access
app.securityv0.com (prod)NoneWorkOS hosted login (application layer)WorkOS bearer JWT only — no CF service-token headers needed
dev.securityv0.com (dev)"SecurityV0 Dev" Access appEmail OTP / GitHub org via CF, then WorkOS hosted loginCF-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 DevSame as Dev
pr-N.sv0-reviews.pages.dev (visual review reports)None (intentionally — public Pages)Openn/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:

ProviderWho It's ForWhy
One-Time PIN (OTP) via EmailTeam members with @securityv0.com emailZero setup — Cloudflare sends a code to the email.
GitHub OAuthTeam members with GitHub accounts in the securityv0 orgNatural for developers; org-based policy.

Email OTP is the primary method; GitHub is a convenience layer for developers already logged in.


Access applications

ApplicationDomain(s)Policy
SecurityV0 Devdev.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 / WorkflowMechanism
deploy-dev.ymlSSH goes direct to server IP (no CF). External health checks against dev.securityv0.com use *_DEPLOY service token headers.
deploy-prod.ymlSSH 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, MongoDBInternal traffic only, not exposed through Cloudflare.

Service tokens

Two service tokens, kept separate so they can be rotated independently:

  1. ci-deploy-bot — used by GitHub Actions deploy workflows
  2. visual-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.

ElementValue
Logofavicon.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
FontInter 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

SettingValueRationale
Session duration24 hoursRe-auth once per day, low friction for team
Cookie domain.securityv0.comCovers all subdomains
WARP requirementOffNot needed at current team size
Purpose justificationOffNot 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):

  1. Missing or expired WorkOS bearer JWT — re-run npm run auth:login (or refresh SV0_API_TOKEN in CI).
  2. Missing connector API key — Authorization: ApiKey sv0_<env>_<64hex> header.
  3. Missing tenant header — x-tenant-id: <slug> is mandatory on all /api/v1/* reads.
  4. delegated_agent session hitting an admin-only route — use a cookie session instead, or insert directly via Mongo (see the reference_connector_api_key_direct_mongo_insert memory).

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):

  1. Missing cf-access-client-id / cf-access-client-secret headers — load from sv0-platform/.env.
  2. Stale or revoked service token — Ivan re-pulls from 1Password vault SecurityV0 items Cloudflare Access — visual-review-bot / Cloudflare Access — ci-deploy-bot (see cf-access-service-token-setup.md).
  3. 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

ItemCostNotes
Cloudflare Zero Trust (Free)$0/monthUp to 50 users
GitHub OAuth App$0Free to create
Service Tokens$0Included in Zero Trust
Custom branding$0Available on free tier

Total: $0/month.


Risks and mitigations

RiskImpactMitigation
Service token leaked in logsBot access compromisedMask secrets in CI, rotate tokens quarterly
Cloudflare outage blocks dev accessDev team locked out of dev URLsSSH direct to servers is the emergency backdoor
OTP emails delayedLogin frictionGitHub OAuth as fallback IdP
Wildcard policy misconfiguredPR previews unprotectedTest with pr-test-dev.securityv0.com before going live
Service token connectivity fails silentlyVisual review / deploy passes without real authValidate response body (JSON status=ok), not just HTTP status code

Decision log

DecisionChosenAlternatives ConsideredRationale
Perimeter auth providerCloudflare AccessAuthelia, Authentik, Tailscale Funnel, Basic AuthAlready on Cloudflare, zero cost, no self-hosting, branded login pages, Service Tokens for bots
Primary IdPEmail OTPGoogle Workspace, OktaZero setup cost, works immediately, no external IdP dependency
Secondary IdPGitHub OAuthNoneNatural for dev team, org-based policy, free
Bot accessService TokensIP allowlist, WARP connector, mTLSSimplest to implement, works across all environments, no network config changes
Session duration24 hours1 hour, 7 daysBalance between security and convenience
Prod perimeterWorkOS 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