Declaring one
What this capability is for is the overview; rotating one is the operation you will run most.
Nothing writes a name the registry has never heard of, so the entry comes first — every field is in the reference:
registry: {
ACME_WEBHOOK_SECRET: {
backend: "d1",
scope: "environment",
valueType: "text",
origin: { kind: "obtained", issuer: "acme" },
rotation: { kind: "manual", issuer: "acme" },
},
},Those declarations are what let one command do the right thing for it forever after. Rotation branches on the declaration and on nothing else — never on a name.
The four axes
backend — d1 for anything only your Worker reads; the Cloudflare store when something outside the Worker needs it.
It is a storage decision, not a read-time one. Moving a secret between the two is a one-line registry edit and every read site stays byte-identical.
scope — environment for a different value per environment; global for one value everywhere.
origin — whether the kit can produce the value. Set the dev value to random when it is arbitrary: any string works because nothing outside the project has to agree with it. Leave it off when the value must match something issued elsewhere.
rotation — local, provider or manual. And rotateEveryDays is separate, and worth setting on the ones nothing can rotate: a third-party key no automation will ever roll is exactly the one whose drift nothing else surfaces.
Reading one
Through the shared per-invocation accessor, not the low-level reader.
const key = await secrets.get("ACME_WEBHOOK_SECRET");Several capabilities each read secrets within one request. Resolving independently means a round-trip per call site, and a repeated one wherever a secret is shared.
So the 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.
Versions
const { current, versions } = await secrets.getVersions("ACME_WEBHOOK_SECRET");Every stored value is an envelope carrying its current version and every still-valid one — which is what lets a webhook verifier accept a signature from either side of a rotation while the old one drains.
Typed values
A JSON secret carries a schema, and it is validated on read. So a credential with two halves comes back as an object rather than a string you parse:
const { clientId, clientSecret } = await secrets.get("auth-google-credentials");Both halves travel together because the pair is atomic — the client id never usefully splits off into config.
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, through the accessor you already hold — never by a command. pithy secrets ls marks them, because a keyspace is the one entry an operator must not try to set.
The name is bound into the ciphertext
Not encrypted — bound, as authenticated data.
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.
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.
Nothing renames a secret, which is what makes that safe: a rename is a write under the new name and a delete of the old, re-sealing 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.
Setting a value
printf '%s' "$THE_VALUE" | pithy secrets create ACME_WEBHOOK_SECRET --env prodFrom stdin when piped, from a masked prompt otherwise. Never from a flag — that would leave a live credential in shell history and in every process list on the machine.
Nothing prints a value back, on any command, in either output mode.
Locally
<config>/<project>/secrets.jsonc is the source, not a copy. Edit a value and the next pithy dev hands the Worker the new one, with no intermediate command.
pithy secrets editOpens it, validates what comes back, writes it atomically. It prints a path and a count, never a name and never a value — listing names is a different command.