Mint, list, rotate and revoke the scoped, least-privilege, account-owned Cloudflare API tokens a project needs.
A minted token is a secret like any other, so pithy secrets is where it lands — and a CI pipeline is the main reason to mint one.
Synopsis
pithy token mint <profile> [--env <env>] [--store <store>] [--permission <key>]… [--json]
pithy token list [--env <env>] [--json]
pithy token rotate <profile> [--env <env>] [--store <store>] [--permission <key>]… [--keep-previous] [--json]
pithy token revoke <profile> [--env <env>] [--json]The model behind these four commands — the bootstrap token, the delegation rule, what ci-system is, and where each store puts a value — is Cloudflare API tokens. This page is the command surface.
Flags
| Subcommand | Flag | Meaning |
|---|---|---|
mint, rotate, revoke | <profile> (positional, required) | The token profile: ci-system, or a capability’s worker-consumer profile |
| all | --env <env> | Target environment. Default dev |
mint, rotate | --store <store> | Override where the value is written: secrets-store, dev-vars, ephemeral |
mint, rotate | --permission <key> | Override the permission set. Repeatable — every occurrence is collected |
rotate | --keep-previous | Keep the old token as a grace window instead of deleting it. Default false |
| all | --json | Machine-readable output. Default false |
The permission keys --permission accepts are fixed and named in the flag’s own help. An unknown key is refused before anything is minted, so a typo cannot produce a mis-scoped credential.
A --store on the command line wins over the profile’s defaultStore, which wins over the declared backend of the profile’s secret in the registry.
What it does
No subcommand ever prints a token value. Not on stdout, not in --json, not in an audit event, not in an error. That is a property to rely on: the value goes to the store the profile resolves, and the output says only which store and where.
| Store | What it does |
|---|---|
dev-vars | Writes to <config>/<project>/tokens.json, mode 0600, keyed by environment. Nothing in the CLI reads that file. It is a handoff: you open it, copy the value, and paste it into your CI provider’s secrets |
secrets-store | Writes to the Cloudflare Secrets Store for a Worker to read through its binding, with no human in the loop |
ephemeral | Writes nothing at all |
mint rolls in place. The token name is a stable (project, env, profile) identity — <project>-<env>-<profile> — and each mint regenerates the value with the profile’s current permissions. So composing a capability that contributes ciPermissions takes effect on the next mint, and re-minting never orphans a token. It does not reuse a stored value; that would pin the token to its old scope.
list reports this project’s tokens for one environment, identities only. Two gates decide what appears: the <project>-<env>- prefix, because Cloudflare’s account token list is flat and account-wide and includes other projects’ credentials as well as your own; and a reverse lookup from the known profile names, because the naming facade may truncate a long trailing segment, so slicing the prefix off would hand back a string rotate and revoke cannot resolve. A prefixed token no profile claims is therefore not listed.
rotate is the two-step Cloudflare has no single call for: mint a new token under the same name and policies, store the value, then delete the tokens that predate it. --keep-previous leaves the old one standing as a grace window while a Worker consumer picks up the new value — redeploy first.
revoke deletes every account token of the profile’s computed name for that environment. The project scope in that name is what keeps the sweep inside this project.
Every mint, rotate and revoke emits an audit event carrying the token’s id and its store, never its value — and only when the project composes @pithy-sh/audit. It is a no-op otherwise; the CLI never hard-depends on audit, and an audit write never breaks the token action it records.
Credentials come from <config>/cloudflare.json, or <config>/cloudflare.<accountName>.json when the root pithy.config.ts names an account. They are account-scoped rather than per project, and the account is resolved from the project’s own config before the credentials are read — minting into a file the project did not select would create a real token in somebody else’s account.
--json
One line on stdout. A failure is one {"error": …} line on stderr and a non-zero exit.
mint and rotate
| Key | Type | Meaning |
|---|---|---|
command | string | "token mint" or "token rotate" |
profile | string | The profile that was minted |
env | string | The environment it was minted for |
tokenId | string | Cloudflare’s id for the token. An identifier, not a credential |
store | string | Where the value went: "secrets-store", "dev-vars" or "ephemeral" |
location | string | A human location for that store: the tokens.json path, "CF Secrets Store", or "(ephemeral — not written)" |
The token’s value and its composed Cloudflare name are both on the internal result and both deliberately absent here.
list
| Key | Type | Meaning |
|---|---|---|
command | string | "token list" |
env | string | The environment listed |
tokens | object[] | This project’s tokens for that environment. Empty when there are none |
tokens[].profile | string | The profile, recovered by exact-name lookup |
tokens[].env | string | The environment — the same one, on every row |
tokens[].name | string | The composed Cloudflare token name, <project>-<env>-<profile> |
tokens[].tokenId | string | Cloudflare’s id for the token |
tokens[].status | string, optional | The token’s lifecycle status as Cloudflare reports it: "active", "disabled" or "expired". Absent when the account API returned none |
revoke
| Key | Type | Meaning |
|---|---|---|
command | string | "token revoke" |
profile | string | The profile that was revoked |
env | string | The environment it was revoked for |
name | string | The composed Cloudflare token name the sweep matched |
revokedCount | number | How many account tokens of that name were deleted. 0 is an ordinary answer |
It is revokedCount rather than revoked on purpose: a plain revoked reads as was it revoked in one reading and how many in another, and revoked: 0 and revoked: false are not the same claim.
Errors
Cloudflare credentials are missing.SetCLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_API_TOKEN— the bootstrap token — or record them withpithy init.Unknown token permission key: <key>.The refusal lists every legal key.Unknown token store: <value>.Legal values aresecrets-store,dev-vars,ephemeral.Unknown token profile: <name>.The refusal lists the profiles this project actually resolves — the aggregate of its composed capabilities’ declarations and its config overrides.- No storage is declared for the token. The profile names no store and its secret is not in the registry. Declare the secret as
cf-secrets-store, or mint with--store dev-vars. - The secret is declared
d1. A token whose value is read outside the Worker cannot live in the encrypted D1 store. Same two fixes. No CF Secrets Store is configured for the secrets-store store.Runpithy add secretsto recordSECRETS_STORE_ID, or mint with--store dev-vars.- No project name. Every token name and Secrets Store entry starts with it, and
revokedeletes every account token of the name it computes — a guess would point that sweep at another project’s credentials. --envis validated at the flag.productionis answered withprod; anything over seven characters or outside the character set is refused before a single Cloudflare call.
Examples
pithy token mint ci-system --env prod --json
pithy token mint ci-system --env prod --store ephemeral --permission workers:write --permission d1:write
pithy token list --env prod --json
pithy token rotate secrets --env prod --keep-previous --json
pithy token revoke ci-system --env staging --json{"command":"token mint","profile":"ci-system","env":"prod","tokenId":"<token-id>","store":"dev-vars","location":"/home/you/.config/pithy/acme/tokens.json"}
{"command":"token list","env":"prod","tokens":[{"profile":"ci-system","env":"prod","name":"acme-prod-ci-system","tokenId":"<token-id>","status":"active"}]}
{"command":"token revoke","profile":"ci-system","env":"staging","name":"acme-staging-ci-system","revokedCount":1}Every value above that looks like an identifier is a placeholder. No payload on this page can carry a token value, whatever is substituted in.