Playbook: Cloudflare API Token Rotation Cleanup

When to run

After any credential-rotation.yml run that rotated CLOUDFLARE_TOKEN_ZINTEGROWANA or CF_EDIT_ALL_ZONES_API_TOKEN. The rotation script creates NEW tokens but intentionally does not delete old ones (to avoid downtime if the new value fails to deploy). This playbook handles the manual cleanup step.

Confirm stale tokens exist

$env:CF_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^CF_GLOBAL_API_KEY=").ToString().Split("=",2)[1]
$headers = @{ "X-Auth-Email" = "radieu@gmail.com"; "X-Auth-Key" = $env:CF_KEY; "Content-Type" = "application/json" }
$resp = Invoke-RestMethod -Uri "https://api.cloudflare.com/client/v4/user/tokens" -Headers $headers -Method GET
$resp.result | Where-Object { $_.name -match "zintegrowana-dns|all-zones-edit" } |
  Select-Object name, id, status, issued_on | Sort-Object name, issued_on | Format-Table -AutoSize
$env:CF_KEY = ""

Multiple tokens with the same name mean stale copies are present. For each name group, keep the token with the latest issued_on and delete the rest.

Delete stale tokens

$env:CF_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^CF_GLOBAL_API_KEY=").ToString().Split("=",2)[1]
$headers = @{ "X-Auth-Email" = "radieu@gmail.com"; "X-Auth-Key" = $env:CF_KEY; "Content-Type" = "application/json" }
$resp = Invoke-RestMethod -Uri "https://api.cloudflare.com/client/v4/user/tokens" -Headers $headers -Method GET
 
# For each name group, keep newest, delete rest
$groups = $resp.result |
  Where-Object { $_.name -match "zintegrowana-dns|all-zones-edit" } |
  Group-Object name
 
foreach ($g in $groups) {
  $sorted = $g.Group | Sort-Object issued_on -Descending
  $keep = $sorted[0]
  Write-Host "KEEP [$($keep.issued_on)] $($keep.name) $($keep.id)"
  foreach ($old in $sorted[1..($sorted.Count-1)]) {
    $r = Invoke-RestMethod -Uri "https://api.cloudflare.com/client/v4/user/tokens/$($old.id)" `
      -Headers $headers -Method DELETE
    $status = if ($r.success) { "DELETED" } else { "FAILED: $($r.errors | ConvertTo-Json -Compress)" }
    Write-Host "$status  [$($old.issued_on)]  $($old.name)  $($old.id)"
  }
}
$env:CF_KEY = ""

Verify cleanup

Re-run the listing command — each name group should have exactly one token remaining.

Delete superseded tokens via script (preferred, issue #1549)

The PowerShell one-liners above are still valid for ad-hoc/manual use, but the preferred path is now scripts/rotate/cloudflare-token-cleanup.js — it does the same list → filter → exclude-live → delete sequence, but is dry-run/list-only by default and only deletes when explicitly told to.

$env:CF_GLOBAL_API_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^CF_GLOBAL_API_KEY=").ToString().Split("=",2)[1]
$env:CF_LIVE_TOKEN_ZINTEGROWANA = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^CLOUDFLARE_TOKEN_ZINTEGROWANA=").ToString().Split("=",2)[1]
$env:CF_LIVE_TOKEN_ALL_ZONES = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^CF_EDIT_ALL_ZONES_API_TOKEN=").ToString().Split("=",2)[1]
 
# 1. Dry run first -- always. Lists candidates and the live ids it will exclude. Deletes nothing.
node scripts\rotate\cloudflare-token-cleanup.js
 
# 2. Only after reviewing the dry-run output, actually delete the listed candidates:
node scripts\rotate\cloudflare-token-cleanup.js --confirm
 
$env:CF_GLOBAL_API_KEY = ""
$env:CF_LIVE_TOKEN_ZINTEGROWANA = ""
$env:CF_LIVE_TOKEN_ALL_ZONES = ""

The exclude-live-token rule: the script never deletes by name+date alone. For each credential, it first calls GET /client/v4/user/tokens/verify using the live token VALUE itself (never the Global API Key) to resolve that token’s own id, then hard-excludes that id from anything it deletes — even if the id’s name/issued_on would otherwise match. If a live value isn’t supplied for a credential, that credential’s matches are shown (for visibility) but never deleted. This is the same “verify before revoke” safety property the manual procedure above relies on, just made automatic and repeatable. Full script header docs: scripts/rotate/cloudflare-token-cleanup.js.

After any --confirm run, append an entry to docs/secrets-rotation-log.md (token ids + key names only, never values) and re-run the Verify cleanup listing above.

Why multiple stale tokens accumulate

The rotation workflow can be triggered multiple times (scheduled Monday + manual triggers). Each run creates a new token regardless of how many already exist with the same name pattern. The rotate-credentials.py does NOT delete old tokens by design (safety: new value must be confirmed working before old is revoked).

After cleanup

Confirm the monitoring stack is still working:

  • Grafana accessible: https://grafana.vps-i1.infra.zintegrowana.online
  • Prometheus scraping: check Targets page
  • Alertmanager reachable: https://alertmanager.vps-i1.infra.zintegrowana.online

If any service fails after deletion, check which token value is in secrets/monitoring.env.sops and compare against what’s still active in the CF dashboard — they must match.

Auth used

CF_GLOBAL_API_KEY from .env.local with X-Auth-Email: radieu@gmail.com (Global API Key). This key has full account access to list and delete all tokens. For zone-scoped operations only, use CLOUDFLARE_TOKEN_ZINTEGROWANA from SOPS instead.

Prevention note

Part 1 (this cleanup) is now automatedscripts/rotate/cloudflare-token-cleanup.js (dry-run by default, --confirm to delete) replaces re-typing the PowerShell one-liners above after every rotation.

Part 2 (still open): the durable fix is to close the gap in the rotation pipeline itself — add a post-rotation revoke step to rotate-credentials.py / credential-rotation.yml, gated on the new token verifying active first, so this cleanup runs automatically right after every rotation instead of needing a separate manual or dispatched pass. That remaining scope is tracked on issue #1549.