Rotation SOP — Cloudflare Access Service Token
Mode: programmatic Last validated: 2026-05-01 UTC Validation method: read-only-docs (sandbox-rotation feasible — see below) Average duration: 3m Required role: superadmin
Applies to (new taxonomy names — #754):
- CF_ACCESS_SVC_CONSOLE (was CF_ACCESS_SERVICE_TOKEN_CONSOLE)
- CF_ACCESS_SVC_VAULT (was CF_ACCESS_SERVICE_TOKEN_VAULT_PROBE)
- future per-surface service tokens following the CF_ACCESS_SVC_<SCOPE> pattern
These are issued by Cloudflare Access (zero-trust), not user tokens — different API surface from cloudflare-user-api-token.md.
Legacy names (CF_ACCESS_SERVICE_TOKEN_CONSOLE, CF_ACCESS_SERVICE_TOKEN_VAULT_PROBE) continue to work until removed by the cleanup card (see docs/archive/secrets/cf-token-taxonomy.md).
Sandbox validation note: This SOP supports safe sandbox validation by creating a throwaway service token, rolling it, then deleting it. Operator should run the sandbox once before relying on this SOP for production tokens. The validation script is in
scripts/ops/validate-cf-access-service-token-rotation.sh(to be created — see follow-ups).
When to run
- Scheduled rotation (cadence: every 90 days)
- Operator-initiated (suspected compromise, off-cycle)
- After incident (vendor breach, employee offboarding)
- When the service token's TTL expiry is within 14 days (Cloudflare emails the operator one week before expiry)
Prerequisites
- [ ]
CF_ACCESS_MGMT(wasCLOUDFLARE_ACCESS_MGMT_TOKEN) available withAccess: Service Tokens Writepermission, scoped to the correct account - [ ]
CF_ACCOUNT_IDknown (visible athttps://dash.cloudflare.com→ account selector) - [ ]
TOKEN_IDof the service token to rotate (fromGET /accounts/{id}/access/service_tokens) - [ ] Downstream consumer list (which Cloudflare Access policies reference this token, which Heroku apps need the new client_id+client_secret pair)
- [ ] Existing token version in Infisical with history
Steps
1. Pre-rotation checks
# Resolve management token (new name first, legacy fallback)
MGMT="${CF_ACCESS_MGMT:-$CLOUDFLARE_ACCESS_MGMT_TOKEN}"
ACCOUNT="${CF_ACCOUNT_ID:-$CLOUDFLARE_ACCOUNT_ID}"
# List service tokens to find the one to rotate
curl -sS -H "Authorization: Bearer $MGMT" \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/access/service_tokens" \
| jq '.result[] | {id, name, client_id, expires_at, duration}'
2. Generate the new credential
Cloudflare Access service tokens are not "rolled" in place like user API tokens — the documented programmatic rotation is create new → swap consumers → delete old. The "Refresh" path (extending expiry) is not a rotation; it preserves the existing client_secret.
# Create a new service token with a parallel name (suffix with date)
NEW_TOKEN=$(curl -sS -X POST \
-H "Authorization: Bearer $MGMT" \
-H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/access/service_tokens" \
--data '{"name":"raxx-console-svc-2026-05-01","duration":"8760h"}')
NEW_CLIENT_ID=$(echo "$NEW_TOKEN" | jq -r '.result.client_id')
NEW_CLIENT_SECRET=$(echo "$NEW_TOKEN" | jq -r '.result.client_secret')
NEW_TOKEN_ID=$(echo "$NEW_TOKEN" | jq -r '.result.id')
# client_secret is shown ONCE. Capture both client_id and client_secret now.
3. Validate the new credential
If the service token authenticates a request to a CF-Access-protected origin, test that:
curl -sS -o /dev/null -w "%{http_code}\n" \
-H "CF-Access-Client-Id: $NEW_CLIENT_ID" \
-H "CF-Access-Client-Secret: $NEW_CLIENT_SECRET" \
https://console.raxx.app/health
# Expect: 200
Note: the new service token will only succeed if the corresponding Cloudflare Access application policy already lists either: - The new token's client_id explicitly (update the policy if scoped by client_id), OR - The token's parent service-token group (preferred — the policy admits any token in the group, so rotation does not require a policy edit).
If the policy is scoped by client_id, update the policy before swapping consumers (otherwise the new token returns 403).
4. Store in Infisical
# New taxonomy names (primary) + legacy companions (backward compat during migration)
infisical secrets set CF_ACCESS_SVC_CONSOLE="$NEW_CLIENT_SECRET" \
--path /MooseQuest/cloudflare/ --env=prod
infisical secrets set CF_ACCESS_SVC_CONSOLE__CLIENT_ID="$NEW_CLIENT_ID" \
--path /MooseQuest/cloudflare/ --env=prod
# Legacy aliases — remove after cleanup card
infisical secrets set CF_ACCESS_SERVICE_TOKEN_CONSOLE="$NEW_CLIENT_SECRET" \
--path /MooseQuest/cloudflare/ --env=prod
infisical secrets set CF_ACCESS_SERVICE_TOKEN_CONSOLE__CLIENT_ID="$NEW_CLIENT_ID" \
--path /MooseQuest/cloudflare/ --env=prod
Both client_id and client_secret are needed by consumers — they are stored as a pair.
5. Propagate to downstream consumers
| Consumer | How |
|---|---|
| Console health probe (raxx-console-prod) | heroku config:set CF_ACCESS_SVC_CONSOLE="$NEW_CLIENT_ID:$NEW_CLIENT_SECRET" -a raxx-console-prod (also set legacy: heroku config:set CF_ACCESS_SERVICE_TOKEN_CONSOLE="$NEW_CLIENT_ID:$NEW_CLIENT_SECRET" -a raxx-console-prod) |
| Other origins behind CF Access | Update each origin's consumer of the token similarly |
6. Verify downstream
# Console health probe should now pass with the new token
curl -sS https://console.raxx.app/api/status/sites \
-H "Cookie: <ops session>" | jq '.sites[] | select(.id=="console-prod").liveness.ok'
# Expect: true
7. Revoke the old credential
curl -sS -X DELETE \
-H "Authorization: Bearer $MGMT" \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/access/service_tokens/$OLD_TOKEN_ID"
# Expect: {"success":true,...}
Per Cloudflare docs: "Revokes all access immediately. Existing sessions become invalid."
8. Audit log entry
action: secret.rotate.completed
actor: <admin_id>
context: {"secret_name": "CF_ACCESS_SVC_CONSOLE", "old_token_id": "...", "new_token_id": "...", "method": "programmatic"}
Rollback
The old service token is preserved until step 7 — until then, both old and new tokens are valid. To roll back: skip step 7 and revert the consumer config (heroku config:set back to the old client_id/secret pair from Infisical history). The old token continues to work until you DELETE it.
After step 7 (DELETE) the old token cannot be recovered — generate a fresh replacement and re-propagate.
Vendor doc references
- Service tokens overview: https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/
- API:
POST /accounts/{id}/access/service_tokens(create),DELETE /accounts/{id}/access/service_tokens/{token_id}(revoke) - "This is the only time Cloudflare Access will display the Client Secret." — capture-once warning.
Known gotchas
client_secretis shown once. Capture immediately or recreate.- Default duration is 8760h (1 year). Set explicitly in the create request; default may change.
- Refresh ≠ rotate. Cloudflare's "Refresh" button extends
expires_atbut preserves the secret — that's not rotation. Always use create-new + delete-old for rotation. - Policy edits may be required if the Access application policy is scoped by
client_id. Prefer scoping by service-token group to avoid this. - DELETE is immediate and irrevocable. Always run step 6 verification before step 7.
- Two service tokens with the same role can coexist during the swap — there is no "max 1 per group" limit. Use this for atomic swaps.