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/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).
CF_ACCESS_MGMT (was CLOUDFLARE_ACCESS_MGMT_TOKEN) available with Access: Service Tokens Write permission, scoped to the correct accountCF_ACCOUNT_ID known (visible at https://dash.cloudflare.com → account selector)TOKEN_ID of the service token to rotate (from GET /accounts/{id}/access/service_tokens)# 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}'
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.
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).
# 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.
| 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 |
# 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
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."
action: secret.rotate.completed
actor: <admin_id>
context: {"secret_name": "CF_ACCESS_SVC_CONSOLE", "old_token_id": "...", "new_token_id": "...", "method": "programmatic"}
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.
POST /accounts/{id}/access/service_tokens (create), DELETE /accounts/{id}/access/service_tokens/{token_id} (revoke)client_secret is shown once. Capture immediately or recreate.expires_at but preserves the secret — that's not rotation. Always use create-new + delete-old for rotation.client_id. Prefer scoping by service-token group to avoid this.