Skip to main content

Cloudflare Access — Service Token Setup for Bot Access

Context

SecurityV0 uses Cloudflare Zero Trust (Access) to protect web properties. Human users authenticate via email OTP or GitHub OAuth. Bots and CI pipelines need programmatic access via Service Auth tokens — a CF-Access-Client-Id + CF-Access-Client-Secret header pair that bypasses the login page.

This runbook covers adding Service Auth policies to Access applications so bots can reach protected sites.

Prerequisites

  • Admin access to the Cloudflare Zero Trust dashboard (https://one.dash.cloudflare.com)
  • The SecurityV0 account selected

Current State

SiteAccess ApplicationService AuthStatus
app.securityv0.comSecurityV0 Productionci-deploy-botWorking
dev.securityv0.comSecurityV0 Devci-deploy-bot, visual-review-botWorking
sv0-reviews.pages.devsv0-reviews✅ GitHub org policyWorking
sv0-docs.pages.devsv0-docs❌ No service token policyNeeds fix

Step-by-Step: Add Service Auth to an Access Application

1. Create a Service Token (if one doesn't already exist)

  1. Go to Zero Trust → Access → Service Auth
  2. Click Create Service Token
  3. Name it descriptively (e.g., bot-docs-access)
  4. Set duration (recommend 1 year, with a calendar reminder to rotate)
  5. Copy both values immediately — the secret is shown only once:
    • CF-Access-Client-Id → store as CF_ACCESS_CLIENT_ID
    • CF-Access-Client-Secret → store as CF_ACCESS_CLIENT_SECRET

Reuse existing tokens when possible. If ci-deploy-bot already exists and the same bots need access, just add it to the new application's policy — no need to create a new token.

2. Add a Service Auth Policy to the Access Application

  1. Go to Zero Trust → Access → Applications
  2. Find the application (e.g., sv0-docs)
  3. Click Edit → go to the Policies tab
  4. Click Add a policy
  5. Configure:
    • Policy name: Service Auth - bot access
    • Action: Service Auth
    • Include: Service Token → select the token (e.g., ci-deploy-bot)
  6. Save the policy

3. Test

# Should return page content (200), not a 302 redirect
curl -sI https://sv0-docs.pages.dev \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"

# Verify: without headers, should still 302 to login
curl -sI https://sv0-docs.pages.dev

Expected results:

  • With headers: HTTP/2 200 + page content
  • Without headers: HTTP/2 302 → redirect to securityv0.cloudflareaccess.com

4. Distribute Credentials

For local development (.env) — canonical runtime source:

  • sv0-platform/.env is the canonical runtime source for CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET. Every agent / script / scan reads from here at runtime.
  • Scripts like visual-screenshot.ts read these automatically via process.env (loaded by scripts/lib/load-env.ts).
  • Run source .env before using curl, or use dotenv in scripts.
  • 1Password is rotation backup only — never the runtime source. If .env is missing the variables, Ivan re-populates them from 1Password (see step below). Agents must not read from 1Password directly (see feedback_no_1password_access memory).

For CI/CD (GitHub Actions):

  • Add to repo secrets: CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET
  • Reference in workflows via ${{ secrets.CF_ACCESS_CLIENT_ID }}

For 1Password (backup/rotation):

  • Tokens are stored in the SecurityV0 vault under "Cloudflare Access — visual-review-bot" and "Cloudflare Access — ci-deploy-bot"
  • Use op item get "Cloudflare Access — visual-review-bot" --format json to retrieve

For bot agents (OpenClaw):

  • Add to the bot's environment variables
  • Bots use these headers when fetching protected URLs

Usage in Code

curl

curl https://sv0-docs.pages.dev \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"

Node.js fetch

const response = await fetch("https://sv0-docs.pages.dev", {
headers: {
"CF-Access-Client-Id": process.env.CF_ACCESS_CLIENT_ID!,
"CF-Access-Client-Secret": process.env.CF_ACCESS_CLIENT_SECRET!,
},
});

Playwright (for visual review / screenshots)

const context = await browser.newContext({
extraHTTPHeaders: {
"CF-Access-Client-Id": process.env.CF_ACCESS_CLIENT_ID!,
"CF-Access-Client-Secret": process.env.CF_ACCESS_CLIENT_SECRET!,
},
});

Troubleshooting

SymptomCauseFix
Still getting 302 with headersService Auth policy not added to the applicationCheck Access → Applications → Policies
403 ForbiddenToken exists but not included in the app's policyAdd the token to the application's Service Auth policy
401 UnauthorizedToken expired or secret wrongRegenerate token in Access → Service Auth
Works for dev.securityv0.com but not sv0-docs.pages.devDifferent Access applications — each needs its own policyAdd the token to the sv0-docs application specifically

Token Rotation

Service tokens should be rotated periodically:

  1. Create a new token in Access → Service Auth
  2. Add the new token to all relevant Access application policies
  3. Update secrets in GitHub Actions and bot environments
  4. Verify access works with the new token
  5. Remove the old token from policies, then delete it

Set a calendar reminder for rotation (recommend annually for non-production, quarterly for production).


-- Echo (sv0-echo)