Setting a secret on the repo is how it stops having an owner
A repo-side secret has no owner, no rotation path and no inventory. One vault, propagation outward, and a guard that refuses the paste.
Pasting a token into a repository's secret settings takes nine seconds and works immediately. That is the whole problem — it is faster than every correct alternative, and it leaves no trace of where the value came from.
A year later nobody can tell you what it is, who issued it, or what breaks when it expires.
The case this post is about
One paste, into one repository's settings page, which takes nine seconds:
$ repo-cli secret set FIREBASE_SA --body "$(cat sa.json)"
refusing: setting a secret directly on a repository.
Secrets live in the vault; consuming systems receive them by propagation.
add/rotate a value: secrets push <alias>
$ secrets sync-to-ci --alias acme-firebase-sa
The paste works. CI goes green, the deploy succeeds, and the value now exists in exactly one place that nobody can enumerate — not in the vault, not in the registry, and not in anyone's memory a month later.
The cost arrives at rotation. This post follows that one paste, because rotating a secret requires knowing every place it was put, and a paste into a settings UI leaves no record that it happened.
The symptom
The question that exposes it is simple and nobody can answer it: where else is this value?
A credential pasted into one repository tends to be pasted into others. There is no inventory, because the place you pasted it is not a system of record — it is a write-only box that masks the value back to you. So the honest answer to "where else" is a list of guesses, and the only way to improve the list is to ask people what they remember.
The second symptom arrives at rotation. A key is compromised, or simply expires, and the work is not generating a new one — it is finding every consumer. Each one you miss is an outage, discovered when something that runs weekly runs next.
The third is the quietest: nobody knows what the secret is for. The name says API_TOKEN. Which
API, issued under which account, with what scopes, expiring when — none of that lives anywhere.
Why the obvious fix failed
The obvious fix is a naming convention. Call it STRIPE_RESTRICTED_KEY_PROD and the problem of
not knowing what it is goes away.
It solves exactly that one symptom and none of the others. You still cannot enumerate where it is, still cannot rotate without archaeology, and the convention decays the moment someone is in a hurry — which is the same moment they were going to paste it anyway.
The second attempt is a spreadsheet or a wiki page listing every secret and where it lives. This works until the first time reality and the document disagree, and you will not find out which is wrong until an outage. A record nothing enforces is a record of intentions.
The third is to centralise and then also keep the repo copies, treating the vault as the primary and the repo as a cache. That is the most reasonable-sounding failure. Two writers, no arbiter — and the cache is the one CI actually reads, so the vault becomes documentation.
The actual mechanism
One store holds the value. Everything else is propagated outward from it, by a tool, on demand.
The properties that matter:
The vault is the only writer. Not the first writer or the preferred one. Nothing else is permitted to create a secret in a consuming system, which is what makes the inventory question answerable.
Propagation is a command, not a person. A tool reads the vault and pushes to the consumers that need it — CI, a hosting platform, a function runtime. Because it is a tool, it can be re-run, and re-running is how rotation works.
Every alias is registered with its purpose. What it is, which account issued it, what scope, which projects consume it. The registry is the thing that answers "what is this for" without anybody remembering.
Materialisation is local and ephemeral. A developer machine gets the value written to a known path when needed, in the ecosystem's own format, and that path is gitignored. The value is never typed, never pasted, never in shell history.

Those four questions are the whole test. A credential you can answer them about is managed; one you cannot is merely present. And the difference is not how carefully anybody treated it — it is whether the system that holds it was built to answer, or just to store.
The fix
The part that makes it hold is a guard that refuses the nine-second paste.
A rule saying "always use the vault" competes with a faster path that works. The faster path wins whenever someone is tired, which is most of when this happens. So the repo-side set command is blocked outright — the only things permitted to write a secret into a consuming system are the sanctioned propagation modes, each of which reads from the vault.
The guard is narrow on purpose — it refuses one command shape rather than trying to police every way a value could travel:
cmd="$1"
# The nine-second paste: setting a secret directly on a consuming repository.
if printf '%s' "$cmd" | grep -qE 'repo-cli +secret +set\b'; then
cat >&2 <<'MSG'
refusing: setting a secret directly on a repository.
Secrets live in the vault; consuming systems receive them by propagation.
add/rotate a value: secrets push <alias>
propagate to CI: secrets sync-to-ci (reads the vault, writes the repo)
one-off for a human: secrets handoff
Setting it here creates a copy the vault does not know about, which the next
rotation will not reach.
MSG
exit 2
fi
exit 0
The distinction it draws, which is the whole design:
$ repo-cli secret set FIREBASE_SA --body "$(cat sa.json)"
refusing: setting a secret directly on a repository.
Secrets live in the vault; consuming systems receive them by propagation.
add/rotate a value: secrets push <alias>
...
$ secrets sync-to-ci --alias acme-firebase-sa
vault -> repo acme-firebase-sa -> FIREBASE_SA (propagation mode: script)
ok
The second command writes the same secret to the same place. What differs is that the vault now knows the copy exists, so a rotation reaches it — which is the entire property the direct paste destroys, silently, while appearing to work.
The reason to block the command rather than document the preference is that the two paths are not competing on correctness, they are competing on time. The direct set is one command and nine seconds. The propagation path is one command and about the same — but only once it exists, and until it exists the comparison is nine seconds against "go and build the propagation". A rule written before the tooling loses that comparison every time, and loses it to somebody making a perfectly sensible decision under time pressure.
Which is the sequencing lesson: build the sanctioned path first, make it genuinely fast, and only then block the shortcut. A guard introduced before the alternative is ready teaches people that the guard is the obstacle, and that impression outlives the fix.
What the guard cannot catch is a value that travels some other way entirely — pasted into a chat, read from a screenshot, typed into a different tool. The command shape it refuses is the common path and not the only one, and I would rather be clear that this closes a door than claim it closes the room. The vault's real defence against those is that it makes the legitimate path easy enough that nobody is looking for another.
The other half of making it easy is that the propagation has to be visible when it runs. A sync that silently succeeds leaves people unsure whether it worked, and uncertainty sends them back to the direct path to make sure. Printing which alias went to which destination, in which mode, costs one line and removes the doubt that produces the workaround.
That inverts the economics. The vault path becomes the only path that works, which makes it the fastest path by default, and the rule stops depending on anybody's discipline.
The propagation modes are worth naming, because "push to CI" hides a real choice. One runs the sync locally against the consuming API, costing nothing and needing no hosted credentials. One runs inside the hosted pipeline itself, which requires giving that pipeline a key capable of writing keys — a meaningful escalation that deserves a deliberate decision. One is a plain script for environments that have neither. All three read from the same vault; they differ only in where the push executes and what has to be trusted to do it.
Picking among them is the kind of decision that gets made implicitly and then never revisited. Making it explicit, per vault, is most of the value — because the version where a pipeline holds a key that can mint other keys is the one you want somebody to have chosen on purpose.
The same logic applies to the other end. A secret value must never reach a transcript, a log or a chat message — and enforcing that needs an allowlist of operations that cannot emit content, not a denylist of commands that can. I have written separately about a leak that arrived through a command nobody had thought to forbid, which is the general case of why the denylist shape fails.
How we proved it
The evidence is structural, and the sharpest part is what the registry makes answerable.
value exactly one copy, in the vault
propagation a command, re-runnable — which is what rotation IS
inventory the registry answers "where else", by construction
repo-side set refused by a guard, not by a convention
local materialised to a gitignored path, never typed
What this buys at rotation: the work becomes generate, push to vault, re-run propagation. The consumers are known because propagation is what put the value there, so there is no archaeology and no missed consumer discovered three weeks later.
The honest limitation: the guard covers the paths it is attached to. A person with console access can still paste a value into a settings page by hand, and nothing in this system prevents that. What it does is remove every reason to — the sanctioned path is faster and the rule is enforced everywhere automation touches. That is a real gap and a much smaller one than relying on everybody remembering.
What I have not operated: this at a scale with many teams and conflicting ownership. Everything above comes from a setup where the vault could genuinely be made the only writer. Where two organisations each believe they own a credential, the hard problem is not the mechanism — it is agreeing who issues it, and no tool settles that.
What this generalizes to
Any value that can be written in more than one place will be, and the copies will not agree. The question is never how to keep them in sync; it is which one is authoritative and what prevents the others existing.
Give the value one home. Make every other location a push target rather than a place anybody types into. Register what each one is for, so the inventory is a property of the system instead of somebody's memory. And block the fast wrong path, because a convention that competes with a nine-second shortcut loses on the exact afternoon you most needed it to hold.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".