Where it is
In the Pithy config directory, one file per project, outside every checkout.
<config>/<project>/secrets.jsonc — machine-local, outside every checkout, mode 0600 in a 0700 directory.
Nothing to gitignore. Nothing git add -A can reach. Nothing npm pack can carry. Delete the whole clone and the values are still there.
pithy secrets edit opens it; pithy doctor tells you the path.
Why not .dev.vars
Writing a value is pithy secrets; the file’s shape is in the reference.
.dev.vars is exactly what wrangler says it is: env bindings, UPPER_SNAKE, in the Worker’s directory, because that is where wrangler reads it.
Secrets are keyed by the registry secret name verbatim — <capability>-<what>, kebab — because that name is the join key into the registry. A mapping table between the file’s keys and the registry’s names would be one more thing to rot.
The Worker’s .dev.vars is generated from this file, rather than being the source.
Nothing in the file names a destination
The registry already knows each secret’s backend. The seeder derives where a value goes, so the file and the registry cannot disagree about it.
The rule, stated once
A secret’s entry in this file is the precise payload its destination receives. Nothing wraps it, nothing unwraps it, and no secret is an exception.
| Secret | Destination | Payload |
|---|---|---|
| Any ordinary secret | A D1 row, a .dev.vars line, a Secrets Store entry | An envelope |
A bootstrap secret | Its binding, read before any decoder exists | The value itself |
SECRETS_ENCRYPTION_KEYS is the bootstrap case, and it is an instance of the rule rather than an exception to it: it is what the envelope decoder needs in order to exist, so its binding has always carried a bare encryption config. The file used to state an envelope around it and the seeder used to take that envelope off again — one concept’s version written twice, carrying no information, and reported as file corruption by two readers in a row.
The envelope is always full
{
"auth-session-secret": {
"currentVersion": "1",
"versions": { "1": "…" }
}
}Even for a single-version text secret.
It also matches what is actually stored — so dev stops being a shape production never sees, and pithy secrets rotate --env dev exercises the real rotation path rather than a simplified one.
The registry, not a heuristic, is what says which secrets take an envelope.
One widening, for the person editing the file
A json value is written as its own structure rather than as an escaped string inside a string, and the reader serializes it on the way out.
That is a JSON-in-JSON concession, not a wrapper: nothing is added and nothing is removed. What is stored is the serialized form; what you type is the structure.
Strict parsing, and why
Unknown keys are refused, not stripped.
Stripping is the same permissiveness the format argues against, arriving by the back door. An encryption config is { currentVersion, versions, lastRotatedAt } — a structural superset of an envelope — so a stripping parser accepted the master key’s own value written bare, dropped lastRotatedAt, and left a base64 string where a nested object belongs.
The failure then surfaced three frames later, naming neither the file nor the secret. Refusing at the parse says it once, in place, with the shape spelled out.
The committed example
.dev.secrets.example.jsonc is the one artifact about secrets that stays in your repository, and it is documentation only. It is never copied to a working file — pithy add writes the real one, outside the checkout, and there is nothing in the project for it to sit beside.