Cloudflare’s Create Custom Token screen is fine the first time. By the third rebuild, clicking through dozens of permission rows and re-selecting scopes, then doing it all again for a second account, it stops being fine. We turned that ritual into one script you run once. Here is how it works, what we deliberately left out, and the one platform rule that will cost you an afternoon if you do not know it.
A token you can regenerate in ten seconds is a token you will actually rotate.
01The problem with clicking
Custom API tokens are the right way to talk to Cloudflare from scripts and CI. They are scoped, revocable, and there is no all-powerful global key sitting in an env var. But building them by hand has three quiet costs.
- It is not reproducible. A token assembled by clicking is a snowflake. Nobody can tell you exactly what it can do without opening the dashboard and reading every row.
- It does not scale to multiple accounts. The usual assumption is one token per account, so every account multiplies the clicking, and the number of secrets you now have to store and rotate.
- It discourages rotation. Anything that takes fifteen minutes of careful clicking is something you will avoid doing. Friction is the enemy of good key hygiene.
The goal was simple: one command that mints exactly the token we want, the same way every time, with the rules written down in the script itself.
02One user-owned token can span accounts
The "one token per account" assumption is worth poking at. Cloudflare tokens come in two flavors, and the difference is the whole trick.
- Account-owned tokens belong to a single account and can only ever see that account.
- User-owned tokens belong to you, and a single one can carry policies for every account you belong to. Each policy names its own resources.
So instead of N tokens for N accounts, you mint one user-owned token with one policy per account. Your scripts hold a single secret, and you pick which account a command targets with an environment variable at call time. One thing to store, one thing to rotate, one thing to revoke if it ever leaks.
Be honest about the cost, though. Consolidating cuts the number of secrets and it widens the blast radius. One token across two accounts means a single leak burns both. The per-account split you are giving up was also a wall between them. Worth trading here for the operational simplicity, but a deliberate choice, not a free win.
Mental model
A token is just a name plus a list of policies. Each policy is allow / deny times a set of permission groups times a set of resources. "All zones in an account" and "this whole account" are just different resource selectors. Build the policy list in code and the whole token becomes data you can diff and review.
03Scoped on purpose: cut the dangerous powers
"Manage everything" is a tempting scope and a bad one. This token runs day-to-day infrastructure work: deploy functions, manage storage, edit DNS. It has no business touching the things that turn a convenient token into a catastrophic one.
Be clear-eyed about what this is. It is not textbook least privilege. The token still holds every account- and zone-scoped resource permission across both accounts, with wildcard selectors. That is a broad credential. What we are doing is amputating the genuinely dangerous scopes to shrink the blast radius, not paring down to the exact set one workflow needs. If you can enumerate the precise permissions a job uses, tighter is better. This is the pragmatic middle for a general-purpose ops token.
Cloudflare tags every permission group with a scope, so we could select in code instead of eyeballing a list. The rule we landed on:
- Every account-scoped and zone-scoped resource permission. The stuff that actually runs infrastructure: deploy functions, manage storage, edit DNS.
- User scopes. Account membership and personal profile. A deploy token has no reason to edit who is in the org.
- Billing. Never automate the ability to spend money.
- Token management. A token that can mint tokens can escalate itself. Non-negotiable, and the platform agrees (see the gotcha below).
Because the selection runs off the scope tags, it survives the platform growing. When Cloudflare ships a new product, its resource permissions get picked up on their own, while the excluded categories stay excluded.
04The gotcha that costs you an afternoon
There are two ways to authenticate the call that creates a token, because minting a token itself needs a credential allowed to mint tokens.
- the Global API Key (all-powerful, so use it briefly and never store it), or
- a small, purpose-built bootstrap token whose only power is create API tokens.
The bootstrap token is the tidier, lower-blast-radius choice. It comes with a rule that is not obvious until you hit it.
Error 1001 · "sub-token is not allowed to have permissions to manage other tokens"
A scoped token cannot mint a new token that itself carries token-management permissions. The platform refuses to let a limited credential hand out something more privileged than a sanitized copy of itself. If your permission selection sweeps in the "manage tokens" groups, the create call fails outright.
The fix and the security best practice are the same move: leave
token-management permissions out of the selection. We were already excluding
them by intent. The platform just enforces it. Worth knowing the two are
linked, so that when 1001 shows up you reach for the filter
instead of reaching for the Global API Key to "make it work."
05Prove it actually works
A fresh token deserves suspicion until you have exercised it. Three checks, under a minute, close the loop: identity, a real write, and cross-account reach.
One expected non-error worth calling out: whoami could not show
the account email, because we excluded the "read user details" permission. A
missing capability you chose is a feature, not a bug. Verify against what you
asked for, not against everything lit up green.
06Do it yourself
The whole thing is a short script against Cloudflare’s token API. Here is the shape, in Node. Any language with an HTTP client works the same way. Swap in your account IDs and you are done.
Make a throwaway bootstrap token
In the dashboard, create a custom token whose only permission is User → API Tokens → Edit. This is the credential that mints the real one. You delete it afterward.
Pull the permission catalogue
Fetch every permission group. Each one carries a scopes array telling you whether it is account-, zone-, or user-scoped. That is what you filter on.
Select with rules, not clicks
Keep account- and zone-scoped resource groups. Drop user-scoped, billing, and anything matching "API Tokens".
Build one policy per account
Account resources use a flat selector. "All zones in an account" uses a nested one. Assemble the policy list and post it.
Verify, then destroy the bootstrap token
Run your three checks, then delete the throwaway token. It has served its purpose.
// One user-owned token spanning multiple accounts.
// Auth with a bootstrap token that can only create tokens.
const API = "https://api.cloudflare.com/client/v4";
const ACCOUNTS = { primary: "<account-id-a>", secondary: "<account-id-b>" };
const auth = { Authorization: `Bearer ${process.env.CF_BOOTSTRAP_TOKEN}` };
const call = async (method, path, body) =>
(await fetch(API + path, {
method, headers: { ...auth, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
})).json();
// 1. Pull the catalogue and select by scope tag.
const { result: groups } = await call("GET", "/user/tokens/permission_groups?per_page=100");
const keep = g =>
!g.scopes.includes("com.cloudflare.api.user") && // no membership / profile
!/billing|api tokens/i.test(g.name); // no billing, no token mgmt
const account = groups.filter(g => keep(g) && g.scopes.includes("com.cloudflare.api.account") && !g.scopes.includes("com.cloudflare.api.account.zone"));
const zone = groups.filter(g => keep(g) && g.scopes.includes("com.cloudflare.api.account.zone"));
// 2. One account policy + one all-zones policy, per account.
const policies = Object.values(ACCOUNTS).flatMap(id => [
{ effect: "allow", permission_groups: account.map(g => ({ id: g.id })),
resources: { [`com.cloudflare.api.account.${id}`]: "*" } },
{ effect: "allow", permission_groups: zone.map(g => ({ id: g.id })),
resources: { [`com.cloudflare.api.account.${id}`]: { "com.cloudflare.api.account.zone.*": "*" } } },
]);
// 3. Mint with an expiry and an IP allowlist. A token that never
// expires is one you will never rotate; one bound to your egress
// IPs is far less useful to whoever steals it.
const body = {
name: "infra all-resources",
policies,
expires_on: "<ISO-8601, e.g. 2026-12-31T23:59:59Z>",
condition: { request_ip: { in: ["<your-egress-cidr>"] } },
};
const { result } = await call("POST", "/user/tokens", body);
// The value is shown exactly once. Do not log it into your shell
// history or CI output. Pipe stdout straight to a secret store:
// node mint-token.mjs | wrangler secret put CLOUDFLARE_API_TOKEN
process.stdout.write(result.value);
Then point your CLI at it and switch accounts per command:
export CLOUDFLARE_API_TOKEN="<the-minted-token>"
# identity: should list every account the token reaches
wrangler whoami
# a real write, cleaned up immediately
wrangler kv namespace create verify_roundtrip
wrangler kv namespace delete --namespace-id "<id>"
# reach the OTHER account by selecting it at call time
CLOUDFLARE_ACCOUNT_ID="<account-id-b>" wrangler pages project list
07What to take away
- Prefer a user-owned token when you run more than one account. One secret beats N, as long as you accept the wider blast radius as a deliberate trade.
- Select permissions from scope metadata, not from memory. It is self-documenting and it survives the platform adding new products.
- Exclude token management, billing, and user scopes. Better security, and for token management the platform enforces it anyway (
1001). - Bootstrap with a purpose-built token, not the Global API Key, then delete it.
- Give it an expiry, and an IP allowlist if you can. A token that never expires is one you will never rotate. And never log the secret to a scrollback you keep. Pipe it into a secret store.
- Verify against intent. A capability you chose to omit should be absent. Green everywhere is not the goal. Exactly what you specified is.
- 1 User-owned token, instead of one per account.
- 2 Accounts reached by that single token.
- 1001 The Cloudflare error that enforces the security rule for you.
The deeper win is not the script. It is turning an opaque, hand-built credential into reviewable, regenerable data. Once your access lives in code, rotating it is boring. Boring is exactly what you want from security.
This is the kind of infrastructure work we do for the teams we work with here in the Bay Area, the plumbing behind the sites we build and run. If your Cloudflare access is a pile of hand-clicked snowflakes and you would rather have it defined in code, reach out. We are here for you.