You need: secrets provisioned for the environment.
Say what would happen first
Secrets is the capability, pithy secrets rotate is the command, and credential management is where auth’s own live.
pithy secrets rotate TURNSTILE_SECRET --env prod --dry-runReaches no account, needs no credentials, rolls nothing.
Which is what makes it the thing to type first at 2am, when the question is is this one rolled at somebody else’s API, or minted here?
Three kinds, and the command branches on the declaration
Never on a name.
| Declaration | What happens |
|---|---|
local | The kit produces the value, so it produces another. Minted and stored in one step |
provider | The issuer is called and returns the successor |
manual | A human, in somebody else’s console. It prints the console, the page and the command that records the result, and calls nothing |
A manual rotation exits 0 and never prints Done. — nothing was done.
A provider secret with no rotator is refused by name, with both ways out. The kit ships the tag on one secret and no rotator for it, because rolling that widget needs an account token the package must never hold.
What each rotation costs you
A session signing key signs everybody out. That is correct — a session secret you are rotating is one you no longer trust — but do it deliberately.
A link-signing key stops verifying links already in inboxes. A magic link somebody received an hour ago will not work. Rotate one when you have reason to, not on a schedule you copied from somewhere.
The master key is not rotated by this command at all. It rotates on its own axis, inside the manager, on the manager’s own cron — replacing it here would leave every stored secret sealed under a key nobody holds.
A provider credential is fine to rotate: the store holds both versions while anything signed under the old one drains.
The failure this subcommand is built around
A provider roll succeeds and the store write fails.
The old credential is dead at the issuer. The new one exists only in the process that received it. The environment is holding something that no longer works.
Nothing repairs it by trying harder at the roll. A second roll issues a third credential and loses the second — the retry meant to save it is what destroys it.
So the ordering is the design:
- Every refusal happens before anything is called. An undeclared rotation, a missing rotator, a keyspace, the master key, missing credentials — each answered with nothing rolled and nothing written. A refusal arriving after a roll would be the worst of both.
- The value is produced once, and never again for any reason.
- The store is retried against that value — three attempts, the same string every time. There is no path from a failed store back to a fresh roll.
Exit 3 is its own status
REPLAY_PROVIDER_TOKEN was rolled at cloudflare and its new value was not stored.
prod holds a credential cloudflare has retired.1 means the previous credential is still live and the command can simply be run again. 3 means it is not, and nothing automated will fix it.
A script cannot tell those apart from a message, and the two need opposite reactions. 2 is left alone, because shells use it for usage errors and a status that might mean you typed it wrong or a production credential is dead is no signal at all.
The value is discarded
Not printed, not written to a file, not put in the audit trail. The result has no field that could carry one, so it is structural rather than a habit.
That is a real cost, and the alternative is worth stating rather than implying there wasn’t one. Printing it would put a live production credential in shell scrollback, in the CI log, in the terminal-recording buffer, permanently, for the most sensitive value the command touches. Writing it to a file is the same leak with a filename.
Against that, a rolled-and-unrecorded credential is an outage with a known remedy the declaration can name.
A rotator that throws is reported differently
A rotator that returned and a store that refused means the credential was rolled. A rotator that threw means it may have been — the call reached the issuer and the answer did not.
Both exit 3 and both need a human at the issuer. Only one may be described as rolled, and a report that says so of both is wrong half the time about the one fact being acted on.
So the second says may have been rolled, and its remedy starts with checking rather than with rolling again — because rolling again on an issuer that already rolled produces a second orphan.
There is no --all, and that is a decision
The case is real: somebody has left, and every credential they could have seen needs rolling today.
The blast radius is the obvious argument — the failure above does not average out over ten secrets, it is ten chances to strand a live credential in one invocation, at the hour an operator is least able to read carefully.
The decisive one is different. The precedent for a fleet-wide rotation is more than one confirmation plus an audit entry naming the operator — and the CLI cannot honor the second half. It resolves its actor from the API token, so the one act most certain to be reviewed afterwards would be recorded as somebody with the token.
What the case gets instead is pithy secrets ls and a shell loop, which forces you to see the list before you roll it.
Global means all or nothing
--env on a global secret is refused, not ignored. A global secret holds one value everywhere, so naming an environment asks for something the scope does not permit.
The re-run is the confirmation — there is no flag that skips it. It could have printed a notice and proceeded, but then somebody who typed --env staging would have had production rewritten by a command they did not intend, with a notice after the fact as cover.
And a global fan-out is not a transaction. No ordinary command can create a split, because a narrowed write is refused before anything is dispatched — but a fault partway can, and the command then names the environments it reached before it failed.
The rotation is recorded, whichever path ran it
A row opens before anything is rolled and closes after — so a rotator that never returns still leaves a trace, and one that succeeded advances the clock.
A global secret opens one row per environment: a fan-out that reached staging and stranded production is a success in one history and an incident in the other.
pithy secrets update is a write rather than a rotation and does not advance the clock.
Check it worked
- The dry run names the right kind
- A local rotation stores and the app keeps working
- A manual one prints the console and calls nothing
- The history shows the rotation and the clock moved
- Nothing anywhere printed a value