Raxx · internal docs

internal · gated ↑ index

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/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

Prerequisites

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

Known gotchas