pithy cloudflare

Sign in to Cloudflare in a browser, and choose which session each directory uses.

The sign-in is the first credential a project needs, and every other one is minted from it — Cloudflare API tokens is the model, and pithy token is what mints from it.

Synopsis

pithy cloudflare connect [--profile <name>] [--account <id>] [--no-open] [--json]
pithy cloudflare disconnect [--profile <name>] [--unbind] [--json]
pithy cloudflare use <profile> [dir] [--default] [--json]

Flags

SubcommandFlagMeaning
connect, disconnect--profile <name>The profile to sign in or out. Defaults to the profile bound here, then the project’s cloudflare.accountName, then default
connect--account <id>The account to use when the sign-in can see several and the project pins none
connect--no-openPrint the sign-in address without opening a browser. PITHY_NO_OPEN says the same
disconnect--unbindAlso remove the binding that applies here — this directory’s, a parent’s, or the default
use<profile> (positional, required)The profile to bind
use[dir] (positional)The directory to bind. Defaults to this one
use--defaultMake the profile the one used where no directory is bound
all--jsonMachine-readable output. Default false

What it does

connect signs you in to Cloudflare and keeps a session, so no API token is made by hand. You do not need wrangler login either: every command that runs wrangler hands it the session. It asks Cloudflare for exactly the permissions this project’s tokens carry — ci-system and runtime, in every environment — and nothing beyond them. Those tokens grow with what the project binds, whether a capability declares the binding or your own app does: an r2 bucket asks for R2 Write, a kv namespace for KV Write, a vectorize index for Vectorize Write, a queue for Queues Write and artifacts for Artifacts Write. A project that binds no Vectorize index never asks for a Vectorize permission, and one that composes no media never asks for Images, Stream or Account Analytics, and the consent screen you read says so. A session made before you composed a capability or bound a resource cannot mint what it added: connect again. Outside a project, it asks for what a project with no capabilities would need.

It prints the sign-in address first, every time, then opens it in your browser. It waits for whichever comes back first: the browser’s redirect to http://127.0.0.1:<port>/callback, on a port the system chooses, or the address you paste.

Over SSH, paste the address. When the browser runs on another machine, the redirect lands on that machine, where nothing is listening, so the browser shows This site can’t be reached. That is expected, and the sign-in has worked: copy the page’s whole address from the address bar and paste it at the prompt. connect says so before the browser opens. Without a terminal there is nothing to paste into, so only the redirect is waited for.

Turn on Account API Tokens Write before you approve. Cloudflare lists it under “Sensitive scopes”, switched off, and it is the permission that lets Pithy mint tokens. With it off, Cloudflare grants nothing: it shows We could not complete the authorization. Please check your network connection and try again. and never redirects. connect names the switch before it opens the browser, and again if it times out. A grant that comes back missing any permission it asked for is refused, naming the permission.

A sign-in does not quietly move a profile to another account. With no --profile named, connect refuses to replace a profile’s session for one account with a session for another, because every directory bound to that profile would move with it. Name the profile to make that choice.

The account is discovered, not typed: the project’s cloudflare.accountId decides when it pins one, then --account, then the only account the sign-in can see. When it can see several and nothing decides, an interactive run asks; a run that cannot ask is refused, naming --account and the ids.

The session is written to <config>/session.<profile>.json, mode 0600. The profile is bound to this directory in <config>/profiles.json, which holds directory names and nothing secret. A repository is bound by its main checkout, so its worktrees use the same session. Both files live in the config directory, never in a checkout.

A profile bound to a directory is used by every command run there, and it wins over <config>/cloudflare.json. An exported CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID that differs from the session is refused, naming both, rather than quietly preferring either. A directory nothing is bound to resolves credentials exactly as before, so CI is unaffected: CI has no browser, and keeps running on an account token.

A profile reads its own file: <config>/cloudflare.<profile>.json. The session supplies the pair. The Secrets Store id and R2 credentials come from that file, then from an exported SECRETS_STORE_ID or R2_CREDENTIALS. Under a profile, cloudflare.json is never read, whatever it holds. That file is for a directory with no profile bound. A profile is named after the project’s cloudflare.accountName when --profile names none, so the file that key selects is usually the profile’s own: one file, read by both. A profile file that names a different CLOUDFLARE_ACCOUNT_ID from the session’s has its store id and R2 credentials dropped, and an exported value takes their place. pithy doctor names the file each key came from.

connect records the store id for you. After sign-in it looks up the account’s Secrets Store and writes SECRETS_STORE_ID into cloudflare.<profile>.json, mode 0600, so a new profile needs no hand-made file. The session’s CLOUDFLARE_ACCOUNT_ID goes in beside it, so a profile later signed in to another account drops that store id rather than using it. Cloudflare permits one store per account, so this is a lookup, not a choice. It never overwrites a recorded id: one the account disagrees with is named and left alone. An exported SECRETS_STORE_ID is not a recorded one. When it matches the account’s store, the id is still written to the file. When it does not, the export is named and nothing is written. A profile file that names another account is left alone too, and named. Two stores are named and neither is chosen. An account with no store, a lookup that fails, or a file it cannot write is a sentence, and the sign-in stands, even with SECRETS_STORE_ID exported. pithy add secrets records the id into the same file under a profile. R2 credentials are not looked up, because Cloudflare shows the secret once: write them into the file by hand, or export them.

Audit rows name you. Every audit row the CLI writes under a session names the person who signed in, where an account token can only name itself. pithy doctor reports the bound profile, who is signed in, and which kind of credential is in use.

Registering a dashboard connection takes one. pithy dashboard connect records the person signed in as who authorized the connection, and refuses an account token, in every environment. An account token deploys; it registers nothing.

The access token lasts an hour and renews without a browser. Every command renews it first when it has less than five minutes left, under a lock, so two pithy processes never renew one profile at once. A session that cannot be renewed is refused at the first command that needs Cloudflare, with the connect that fixes it. A command that needs no Cloudflare call never stops over it. Offline (PITHY_OFFLINE) nothing is renewed and nothing is refused for it.

When the project needs more, sign in again. Compose a capability, or bind a resource, that needs a permission the session was not granted, and pithy token mint and pithy token rotate refuse before Cloudflare does, naming the missing permissions. Run connect again and approve them.

disconnect revokes the session at Cloudflare and deletes the file. If Cloudflare does not confirm the revocation, the file is deleted anyway and the command fails, saying so, because the grant may still be live. The binding stays, so the next command run there asks you to sign in again rather than falling back to an exported token. --unbind removes the binding that applies here too, so the directory resolves credentials as if no profile had been bound.

use binds a profile to a directory, or with --default, makes it the profile used where nothing is bound. It does not sign in; it says when the profile has no session yet.

--json

One line on stdout. A failure is one {"error": …} line on stderr and a non-zero exit, by its class. Under connect --json the sign-in address and every instruction go to stderr, so stdout stays one document.

connect

KeyTypeMeaning
commandstring"cloudflare connect"
schemaVersionintegerThe version of this payload’s shape. Bumped only when a key is removed or retyped; pithy schema publishes the current one
profilestringThe profile signed in
emailstring or nullWho signed in. Every audit row the CLI writes under this session names them, and a dashboard connection registered under it records them as who authorized it
accountobjectThe account the session acts on: account.id and account.name
boundTostringThe directory the profile is bound to — a repository’s main checkout
scopesstring[]The permissions granted
expiresAtstringWhen the access token expires, ISO-8601. It renews without a browser
notesstring[]What recording the account’s store id in cloudflare.<profile>.json had to say. Empty when the profile’s file already held the id. An exported id does not count

disconnect

KeyTypeMeaning
commandstring"cloudflare disconnect"
schemaVersionintegerAs above
profilestringThe profile signed out
revokedbooleanWhether Cloudflare revoked a session. false when there was none
sessionDeletedbooleanWhether a session file was deleted from this machine
unboundbooleanWhether this directory’s binding was removed, with --unbind

use

KeyTypeMeaning
commandstring"cloudflare use"
schemaVersionintegerAs above
profilestringThe profile bound
boundTostring or nullThe directory bound, or null when --default set it as the default
signedInbooleanWhether the profile has a session on this machine yet

Errors

  • Cloudflare granted the sign-in without <permissions>. Usually Account API Tokens Write, left switched off under “Sensitive scopes”. Run connect again and turn it on before you approve.
  • No sign-in arrived from the browser. The redirect never came and nothing was pasted. Over SSH, paste the address the browser landed on.
  • Profile <name> already holds a session for account <a>, and this sign-in is for <b>. Pass --profile with a new name for this account, or with that name to replace the session and move every directory bound to it.
  • The account you signed in as can see <n> Cloudflare accounts, and nothing says which one to use. Pass --account with one of the ids it lists.
  • This project pins Cloudflare account <id>, and the account you signed in as cannot see it. Sign in as a member of that account.

Examples

pithy cloudflare connect
pithy cloudflare connect --no-open --json
pithy cloudflare use acme ~/work/acme-api
pithy cloudflare disconnect --unbind --json
{"command":"cloudflare connect","schemaVersion":1,"profile":"acme","email":"jo@example.com","account":{"id":"<account-id>","name":"Acme"},"boundTo":"/home/jo/work/acme-api","scopes":["<scope>"],"expiresAt":"2026-10-15T18:00:00.000Z","notes":[]}
{"command":"cloudflare disconnect","schemaVersion":1,"profile":"acme","revoked":true,"sessionDeleted":true,"unbound":true}

Every value above that looks like an identifier is a placeholder.

ESC