Cloudflare API Token Gotchas: The PUT That Wiped Mine Twice
Three real failures: the self-update that drops scopes, the wrangler auth error nobody explains, and a leaked secret.

A token that worked yesterday throws Authentication error [code: 10000] today. Nothing rotated. Nobody touched the dashboard. Your CI deploy was green on Monday and red on Tuesday with the same secret in the same env var.
I have hit this twice, and both times I did it to myself. The token was fine. The thing that changed it was a script I ran to "fix" its permissions, which is the part that stings. Below are the three gotchas that cost me real hours scripting against Cloudflare's API and running wrangler in CI, with the exact calls and what I do instead now.
Gotcha 1: PUT /user/tokens is a full replace, not a merge
This is the expensive one. I had a long-lived admin token I use across my stack. It needed one extra scope, so the obvious move was to read the token's current policies, add the new permission, and write it back through the API. There is an API Tokens Write permission precisely so a token can edit tokens. Felt clean. It was a trap.
PUT /user/tokens/{id} is a full replace on the token's policy list, not a merge. If the body you send does not echo back every scope the token already had, the omitted ones are dropped. And here is the part that makes it dangerous: the call returns success: true. No error, no warning. The token just quietly comes out with fewer scopes than it went in with.
# DO NOT DO THIS. Shown so you recognise the shape.
# The PUT returns success:true, but any scope you didn't
# include in `policies` is now gone from the token.
curl -X PUT "https://api.cloudflare.com/client/v4/user/tokens/<token-id>" \
-H "Authorization: Bearer cfut_REDACTED" \
-H "Content-Type: application/json" \
--data '{
"name": "my-token",
"policies": [ { "...": "only the scopes I bothered to list" } ]
}'
# => {"success": true, ...} <-- looks fine, isn't
The first time, my admin token lost its user-level permissions during a self-update PUT. The user-detail and membership scopes were not in my request body, so they vanished. Days later a different script that relied on those scopes started failing with code: 10000 and I had no idea why, because the change had succeeded.
You would think reading the token first protects you. It does not. A GET /user/tokens/{id} does not round-trip cleanly back into a PUT body, so even the careful "fetch, modify, write back" pattern is fragile. I tried that on the second go. Same outcome, same token class, wiped again. Twice on the same rake.
The rule I follow now is blunt: never self-update a token's scopes through the public API. If a token needs more permissions, I do not mutate it. I mint a brand new token in the dashboard with the full scope list set at creation time:
- Dashboard, My Profile, API Tokens, Create Custom Token.
- Set every scope I need up front.
- Verify it before trusting it:
curl -s "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer cfut_REDACTED"
# then confirm it can actually reach what you need
curl -s "https://api.cloudflare.com/client/v4/accounts" \
-H "Authorization: Bearer cfut_REDACTED"
Once the new token verifies and hits the real endpoint it is for, I delete the old one from the dashboard to keep things clean, and I set a calendar reminder to rotate, since I prefer a 3 to 6 month TTL over forever-tokens. If I genuinely must extend an existing token, I do it in the dashboard UI. The UI uses an internal path that handles the policy merge correctly. The public PUT /user/tokens/{id} does not, and that asymmetry is the whole gotcha. Mint, don't mutate.
Gotcha 2: wrangler dies on /memberships, and the fix is one env var
Different token, different surface, same code: 10000. My Cloudflare Pages deploy was green for weeks, then two ordinary commits failed in CI with this:
A request to the Cloudflare API (/memberships) failed.
Authentication error [code: 10000]
Are you missing the 'User->User Details->Read' permission?
The deploy command had not changed. What was happening is that wrangler (this was true across wrangler 3.x and 4.x) calls /memberships at startup to resolve which account it is operating on, when you do not tell it explicitly. That endpoint needs the User Details Read permission on the token. A scoped Pages deploy token frequently does not carry that permission, and should not have to.
The fix is not to widen the token. It is to hand wrangler the account ID directly so it skips the membership lookup entirely:
CLOUDFLARE_ACCOUNT_ID="<account-id>" \
CLOUDFLARE_API_TOKEN="cfut_REDACTED" \
npx wrangler@4 pages deploy out/ --project-name=<project> --branch=master
In GitHub Actions with cloudflare/wrangler-action@v3, the same idea, and passing accountId is the line that matters:
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # this is the fix
wranglerVersion: "4.85.0" # pin it; version drift reopens the bug
command: pages deploy out/ --project-name=my-site --branch=master
Add the secret once with gh secret set CLOUDFLARE_ACCOUNT_ID --body "<account-id>". When this bit me, the deploys had been passing fine before, then started failing on two specific commits. The CI token never had User Details Read, and an unpinned wrangler version had started reaching for /memberships. Passing the account ID fixed CI, and the same env-var pattern unblocked me locally in the meantime. Pin the wrangler version too, because a silent minor bump can change the startup behaviour and reopen the exact same failure.
The lesson stacks on Gotcha 1. The instinct in both cases is to add a scope to the token. In both cases the correct move is to leave the token least-privileged and change how you call the API instead.
Gotcha 3: when a secret lands in a log or a paste
Tokens do not only break. Sometimes they leak. A key gets pasted into a chat, echoed into a build log, or printed by a debug line you forgot to delete. The moment a secret hits any retained log, treat it as compromised. Not "probably fine." Compromised. Scrub it and rotate.
Here is the containment drill I run, and I run it inside 30 seconds, before doing anything else:
# 1. Scrub the value out of the log file in place.
sed -i 's|cfut_REDACTED|[REDACTED_CF_TOKEN]|g' /path/to/leaky.log
# 2. Verify there are zero matches left. Must print 0.
grep -cF 'cfut_REDACTED' /path/to/leaky.log
Then the two things people forget:
- Hunt the partial prefixes. Any debug command or grep you ran that included the first few characters of the secret also wrote those characters to the log. Scrub the 4 to 8 character prefix too, not just the full string.
- Stop the bleed at the source. If something keeps capturing pastes (a logger, a shell-history hook), add a narrow redaction regex for that secret's shape so the next paste is masked at capture time, not cleaned up after. Keep the pattern tight. Cloudflare token strings have a recognisable prefix, so match on that, not on anything that would also catch normal prose.
Store the real value once, in a vault file with locked permissions, and never anywhere else:
chmod 600 ~/secrets/cloudflare-token.txt # editors default to 644; force 600
After that: rotate the token in the dashboard, mint a fresh one (see Gotcha 1, never mutate), update the vault, and burn the leaked one. And do not verify your scrub by grepping for the full secret value in later commands, because that grep command itself gets logged. Use a short prefix to check, or test the masking on a fake string. The whole point is to stop adding new copies of the thing you are trying to erase.
How I handle tokens now
Three failures, one through-line: the token is rarely the problem, the way I touch it is. So the policy is short.
| Habit | Rule |
|---|---|
| Scope | Least-privilege. A Pages token deploys Pages, nothing more. |
| Changing scopes | Mint a new token in the dashboard. Never PUT /user/tokens/{id}. |
| wrangler in CI | Pass CLOUDFLARE_ACCOUNT_ID, pin the wrangler version. |
| Storage | One vault file, chmod 600. Not in env files, not in chat. |
| Leak | Assume compromised. Scrub, verify zero matches, rotate. |
| Lifetime | 3 to 6 month TTL, calendar reminder, delete on rotate. |
| Verify | tokens/verify plus the real endpoint before trusting any token. |
Mint, don't mutate. Configure the caller, don't widen the secret. Treat every leak as a rotation. None of this is clever. It is just the set of rules I wish I had before I wiped the same admin token two times in eight days and spent the afternoons after each one chasing a code: 10000 I had created myself.
If you are scripting against Cloudflare beyond tokens, the two CLI surfaces this touches are worth their own reads: the wrangler deep dive covers the deploy flow where the /memberships trap lives, and the Coolify on Oracle ARM walkthrough shows where I keep ports closed behind a Cloudflare Tunnel so a leaked token has less to reach.
Related
More Automation

Fix NVIDIA Cursor and Video Stutter on Linux: GPU Clock Thrash
Cursor jitter and dropped video frames on NVIDIA Linux get blamed on the compositor every time. On my GTX 1660 the real cause was the driver bouncing graphics and VRAM clocks under light load. Here is the fix that held.

Litestream to Cloudflare R2: Disaster Recovery for SQLite
SQLite on one free box is one disk failure away from gone. Here is the exact Litestream-to-R2 setup I run across every PocketBase backend in my stack, including the restore drill and the gotcha that bites first.

Memoize Your LLM Calls to Stop Burning Quota on Answers You Have
Re-running the same deterministic prompt in dev and CI burns quota for an answer you already got. Here is the memoization wrapper I use to replay it from disk at zero tokens, and the one place I never point it.