Every secret your project needs is declared in a registry, and nothing writes a name the registry has never heard of.
That refusal is the feature. A secret that exists in a store and nowhere in your code is a secret nobody will rotate, audit or remember.
Each entry declares what kind of thing it is
The secrets capability is the implementation, pithy secrets is the command, and rotating one is the operation you will run most.
| Declaration | Choices | What it decides |
|---|---|---|
backend | d1, cf-secrets-store | Where the value is stored |
scope | environment, global | Different per environment, or one value everywhere |
valueType | text, json with a schema | What shape the value is |
devValue | random, or absent | Whether the kit can mint a dev value, or a human must supply one |
rotation | local, provider, manual | Who replaces it, and how |
rotateEveryDays | a number | How often it is expected to be rotated |
keyed | a flag | Whether this is one name or a whole keyspace |
Those declarations are what let one command do the right thing for every secret without a list of special cases. pithy secrets rotate branches on the declaration and on nothing else — never on a name.
backend is a storage decision, not a read-time one
The reader routes off these axes, resolves every secret locally with no round-trip of its own, and exposes one uniform interface.
So moving a secret between the two backends is a one-line registry edit and every read site stays byte-identical. In local dev the same names resolve from the generated file; deployed, they resolve from that environment’s store. The call site is identical either way.
Reach for d1 for anything only your Worker reads. Reach for cf-secrets-store when something outside the Worker needs it — a value a deployed capability host reads through its own binding.
Secrets do not ride the ephemeral lifecycle
This is why they live in their own database rather than in the app one.
A deployed feature environment still needs the signing keys and the webhook secrets, and a global secret must be present in every environment at once. The app database is provisioned and torn down per feature branch; the secrets store is a singleton per environment, and the manager that writes it is a singleton too.
The master key never leaves the Worker
A d1 secret is encrypted at rest under a master key that lives inside that environment’s secrets manager Worker. Nothing reads it back out — not the CLI, not a dashboard, not you.
That has one consequence worth holding on to, because it shapes several commands: a stored value cannot be copied from one environment to another. A global secret that ended up in staging and not production cannot be completed by reading staging’s; the repair is to remove it everywhere and mint one fresh.
The key rotates on its own axis, inside the manager, on the manager’s own schedule — 30 days by default, re-encrypting every stored secret. It is the one secret nothing replaces in place, because replacing it would leave everything sealed under a key nobody holds.
The name is sealed in with the value
Every stored value is one envelope carrying its current version and every still-valid one — and the secret’s name is bound into the ciphertext as authenticated data.
Not encrypted. Bound. A ciphertext lifted from one row and written into another does not open, because the name the decryptor supplies no longer matches the one sealed with it.
That includes a keyspace member, whose bound name is the whole entry-and-key pair — so one tenant’s credential does not open under another tenant’s key.
The effect is that which secret a caller gets stops being a property of the query alone. A bug up there becomes a clean cryptographic failure rather than a disclosure.
The name is safe to bind because nothing renames a secret. A rename is a write under the new name and a delete of the old, which re-seals through the plaintext. Editing the name column in raw SQL leaves a row nothing can open — that is the property working rather than a bug.
Resolved once per invocation
Several capabilities each read secrets within one request. Resolving them independently means a round-trip per call site, and a repeated one wherever a secret is shared.
So the shared accessor resolves the combined registry — every capability’s slice merged — exactly once per invocation, caches it briefly, and hands each call site a precisely typed view over only its own slice.
A capability can read what it declared and cannot reach another’s names.
Keyspaces
An entry can declare a keyspace rather than a name: one schema, an unbounded set of members whose keys exist only at runtime — one credential per tenant, say.
Those are written in-Worker by the application that mints them, never by a command. pithy secrets ls marks them, because a keyspace is the one entry an operator must not try to set.
Overdue is a fact the capability states
rotateEveryDays is what turns overdue into something the registry declares rather than a number a dashboard invented.
It is independent of whether anything can rotate the secret automatically — and a third-party key no automation will ever rotate is exactly the one whose drift nothing else would surface.
Your dev values are outside every checkout
<config>/<project>/secrets.jsonc, in the config directory. It is the source, not a file something copies out of: edit a value and the next pithy dev hands the Worker the new one, with no intermediate command.
Nothing to gitignore, nothing git add -A can reach, nothing npm pack can carry. pithy secrets edit opens it.
A value never comes from a flag
create and update read from stdin when piped, and from a masked prompt otherwise. A flag would leave a live credential in shell history and in every process list on the machine.
Nothing in this capability’s surface prints a value back, on any command, in either output mode.