Mint scoped Cloudflare tokens from a master token; use, then delete

One IP-locked token that only mints tokens; POST /user/tokens for a scoped, expiring one per job (permission groups come as Read/Write pairs), then delete it.

Security

· Chapter

20

·

3

min read

The answer. Don't hand one broad token to every script and CI job. Keep one master credential whose only permission is to manage API tokens (/user/tokens), lock it to a known egress IP, and mint from it: fetch the permission-group ids fresh, POST /user/tokens with exactly the groups the job needs, an expires_on, and the account or zone as resources; use result.value; DELETE /user/tokens/{id} when done. Two things the dashboard hides: a UI scope like Workers Scripts: Edit is two API groups, Read and Write — include both or wrangler fails on the read half — and group ids drift, so look them up each time by exact name.

The pattern.

# Master token: only "API Tokens: Edit", IP-locked, read from a 0600 file — never echoed
source /path/to/.env.cloudflare              # exports CLOUDFLARE_API_TOKEN (the master)
BASE=https://api.cloudflare.com/client/v4
H=(-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json")

# 1. Resolve permission-group ids by EXACT name (dashboard ":Edit" = Read + Write)
GROUPS=$(curl -s "$BASE/user/tokens/permission_groups?per_page=1000" "${H[@]}")
ids() { jq -c --arg n "$1" '[.result[] | select(.name==$n) | {id}]' <<<"$GROUPS"; }
PG=$(jq -s 'add' <(ids "Workers Scripts Read") <(ids "Workers Scripts Write"))

# 2. Mint: account-scoped, expiring — second precision only (milliseconds are rejected with a 400)
EXP=$(node -e 'console.log(new Date(Date.now()+90*864e5).toISOString().replace(/\.\d+Z$/,"Z"))')
MINTED=$(curl -s -X POST "$BASE/user/tokens" "${H[@]}" --data-binary @- <<JSON
{ "name": "deploy-my-api", "expires_on": "$EXP",
  "policies": [{ "effect": "allow", "permission_groups": $PG,
                 "resources": { "com.cloudflare.api.account.$ACCOUNT_ID": "*" } }],
  "condition": { "request.ip": { "in": ["$EGRESS_IP/32"] } } }
JSON
)
# 3. Use it without printing it, then delete it
CLOUDFLARE_API_TOKEN=$(jq -r .result.value <<<"$MINTED") npx wrangler deploy
curl -s -X DELETE "$BASE/user/tokens/$(jq -r .result.id <<<"$MINTED")" "${H[@]}" >/dev/null

The receipt. In our infra scripts, 2026-08-12: the deploy token for a new Worker was minted this way — nine permission groups (Account Settings Read plus Read/Write pairs for D1, KV, R2 and Workers Scripts), account-scoped, 90-day expiry — after two surprises: expires_on with milliseconds returns a 400, and the API exposes pairs where the dashboard shows one :Edit. The same mint-use-delete flow removed a third-party SEO Worker's four /* routes from two zones on 2026-08-07 with a token holding only Workers Routes Write and Workers Scripts Write. The master is checked with GET /user/tokens/verify: error 1000 is a bad or blocked token; 10000 is a valid token missing that scope.

Watch out.

  • Fuzzy name matching bites: "Zone Read" is not "Zone Custom Asset Read". Match the exact string.
  • The master's IP lock is the whole point. If a mint call 403s, check your egress IP before weakening anything.
  • Store a minted value only in a gitignored 0600 env file, with CLOUDFLARE_ACCOUNT_ID beside it so wrangler doesn't prompt in CI.

Related: deploy-with-rollback-target-health-probe · webflow-homepage-via-transform-rule-rewrite