Rotation SOP — Cloudflare User API Token
Mode: programmatic Last validated: 2026-05-01 UTC Validation method: read-only-docs Average duration: 5m Required role: superadmin
Applies to (new taxonomy names — #754):
- CF_PAGES_DEPLOY (was CLOUDFLARE_RAXX_AUTOMATION_API_TOKEN)
- CF_ACCESS_MGMT (was CLOUDFLARE_ACCESS_MGMT_TOKEN) — the management token itself; see cloudflare-access-service-token.md for Access service tokens
- CF_PAGES_READ (was CLOUDFLARE_PAGES_READ_TOKEN)
Legacy names (CLOUDFLARE_RAXX_AUTOMATION_API_TOKEN, CLOUDFLARE_ACCESS_MGMT_TOKEN, CLOUDFLARE_PAGES_READ_TOKEN) continue to work until removed by the cleanup card (see docs/archive/secrets/cf-token-taxonomy.md).
Important correction to earlier verbal summary: Cloudflare User API Tokens can be rolled programmatically via
PUT /user/tokens/{id}/value. The dashboard "Roll" button is one path, but it is not the only path. The Global API Key cannot be rolled (and we do not store it). This SOP defaults to the programmatic path; the dashboard path is documented as a fallback for cases where the calling token has lost permission to roll itself.
When to run
- Scheduled rotation (cadence: every 90 days)
- Operator-initiated (suspected compromise, off-cycle)
- After incident (vendor breach, employee offboarding, accidental commit/log of a token value)
- Before token expiry if a TTL was set at creation
Prerequisites
- [ ] Cloudflare dashboard access via SSO (operator)
- [ ] The token's
token_idfrom Cloudflare (visible athttps://dash.cloudflare.com/profile/api-tokensor viaGET /user/tokens) - [ ] An additional Cloudflare token with
User:API Tokens:Editpermission, OR Global API Key access for the dashboard fallback path. (A token cannot always roll itself — see Known gotchas.) - [ ] Existing token version recorded in Infisical with rotation history
- [ ] Downstream consumer list reviewed (which Heroku apps, GitHub Actions secrets, operator zshrc DMs depend on this token)
- [ ] Maintenance window: not strictly required but preferred for
CLOUDFLARE_ACCESS_MGMT_TOKENsince it gates console health probes
Steps
1. Pre-rotation checks
# Confirm the token is currently working (sanity check before rolling)
curl -sS -H "Authorization: Bearer $CURRENT_TOKEN" \
https://api.cloudflare.com/client/v4/user/tokens/verify | jq .
# Expect: {"success":true,"result":{"status":"active",...}}
Identify the token_id to roll:
curl -sS -H "Authorization: Bearer $ROLLING_TOKEN" \
https://api.cloudflare.com/client/v4/user/tokens | jq '.result[] | {id, name, status}'
2. Generate the new credential
Programmatic (preferred):
NEW_VALUE=$(curl -sS -X PUT \
-H "Authorization: Bearer $ROLLING_TOKEN" \
-H "Content-Type: application/json" \
https://api.cloudflare.com/client/v4/user/tokens/$TOKEN_ID/value \
--data '{}' | jq -r '.result')
# $NEW_VALUE is the new token secret. Capture once; not retrievable again.
PUT /user/tokens/{token_id}/value rolls the secret in place — the token_id, name, scopes, and metadata are preserved. The old secret is invalidated atomically.
Dashboard fallback (use only if the programmatic path is unavailable):
1. Navigate to https://dash.cloudflare.com/profile/api-tokens.
2. Locate the token by name.
3. Click the menu icon → Roll.
4. Confirm. Copy the new value (shown once).
3. Validate the new credential
curl -sS -H "Authorization: Bearer $NEW_VALUE" \
https://api.cloudflare.com/client/v4/user/tokens/verify | jq .
# Expect: {"success":true,"result":{"status":"active",...}}
For CLOUDFLARE_PAGES_READ_TOKEN, additionally:
curl -sS -H "Authorization: Bearer $NEW_VALUE" \
https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects | jq '.result | length'
# Expect a non-zero project count.
4. Store in Infisical
Update the Infisical secret in the appropriate project (Raptor backend / console / GitHub Actions). Use Infisical's web UI or CLI:
# Write new taxonomy name (primary) + legacy name (for backward compat during migration)
infisical secrets set CF_PAGES_DEPLOY="$NEW_VALUE" \
--path /MooseQuest/cloudflare/ --env=prod
infisical secrets set CLOUDFLARE_RAXX_AUTOMATION_API_TOKEN="$NEW_VALUE" \
--path /MooseQuest/cloudflare/ --env=prod # legacy alias — remove after cleanup card
Infisical retains version history automatically — the previous value is recoverable for the rollback window.
5. Propagate to downstream consumers
| Consumer | How |
|---|---|
| Heroku Raptor (prod + staging) | heroku config:set CF_PAGES_DEPLOY="$NEW_VALUE" -a raxx-api-prod (and -staging). Also set legacy: heroku config:set CLOUDFLARE_RAXX_AUTOMATION_API_TOKEN="$NEW_VALUE" -a raxx-api-prod. Triggers dyno restart. |
| GitHub Actions | gh secret set CF_PAGES_DEPLOY -a actions -b "$NEW_VALUE" (and set legacy CLOUDFLARE_RAXX_AUTOMATION_API_TOKEN until workflows are updated) |
| Operator local env | Update manually after Infisical sync — DM via Slack D0AJ7K184TV |
Console (raxx-console-prod) |
heroku config:set CF_ACCESS_MGMT="$NEW_VALUE" -a raxx-console-prod (legacy: CLOUDFLARE_ACCESS_MGMT_TOKEN) |
6. Verify downstream
After Heroku dyno restart completes:
# Raptor: confirm the new token is in use by hitting a CF-dependent endpoint
curl -sS https://api.raxx.app/health/dependencies | jq '.cloudflare'
# Expect: {"ok": true, ...}
# Console: confirm CF Pages probes still pass
curl -sS https://console.raxx.app/api/status/sites \
-H "Cookie: <ops session>" | jq '.sites[] | select(.provider=="cloudflare_pages")'
# Expect: liveness.ok = true for all CF Pages sites.
7. Revoke the old credential
The PUT /user/tokens/{id}/value call already invalidated the old secret — no separate revocation step needed. To confirm:
curl -sS -H "Authorization: Bearer $OLD_VALUE" \
https://api.cloudflare.com/client/v4/user/tokens/verify
# Expect: HTTP 401 / {"success":false,"errors":[{"code":1000,"message":"Invalid API token"}]}
8. Audit log entry
Write to console_audit_log via the rotation pipeline callback (#253). Manual operator entry if rotated outside the console UI:
action: secret.rotate.completed
actor: <admin_id>
context: {"secret_name": "CF_PAGES_DEPLOY", "method": "programmatic", "rotated_at": "2026-05-01T12:00:00Z"}
Rollback
The previous token value is invalidated at the moment the new one is generated — there is no "revert to old token" path. Rollback options:
- Roll forward: if the new token fails validation, generate another fresh one (same
PUTcall, repeat until validated). Each call invalidates the previous attempt. - Recover from Infisical history: only useful if the new value was lost before storage. Infisical retains the previous stored value, but Cloudflare does not retain the previous secret. The Infisical history value will not authenticate against Cloudflare after a roll.
- Create a replacement token from scratch: if the rolled token is fundamentally broken (e.g., scope corruption — extremely rare), use the dashboard to create a new token with the same scopes, store in Infisical with a new name, propagate, then delete the broken one.
Vendor doc references
- Roll endpoint: https://developers.cloudflare.com/api/operations/user-api-tokens-roll-token (
PUT /client/v4/user/tokens/{token_id}/value) - Verify endpoint: https://developers.cloudflare.com/api/operations/user-api-tokens-verify-token
- Token creation guide: https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
- Permission templates: https://developers.cloudflare.com/fundamentals/api/reference/permissions/
Known gotchas
- A token may not have permission to roll itself. The
User:API Tokens:Editpermission is required to call the roll endpoint. If the token being rolled lacks that permission (most do, intentionally — least-privilege), use a separate "rotation" token, the Global API Key (we do not store this), or the dashboard. - The new secret is shown exactly once. If you do not capture it in step 2, you must roll again.
- Heroku config var change triggers a dyno restart. Coordinate with current load and any in-progress backtests/rotations.
- Cloudflare-side propagation is immediate but downstream HTTP keep-alive connections may continue to use the old token's connection until the dyno restarts. After
heroku config:setthe restart is automatic; do not skip the verification in step 6. - Token expiry is separate from rotation. If
expires_atwas set at creation, the roll preserves the originalexpires_at. Use the dashboard to extendexpires_atif needed; that path is the "Refresh" button in CF's UI, not the "Roll" button. CLOUDFLARE_ACCESS_MGMT_TOKENrotation does not roll the service tokens it issued. Service token rotation is a separate SOP — seecloudflare-access-service-token.md.