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 automated — scripts/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.