Every place a secret ends up, and what each one costs you
Patterns
Nobody sets out to have their database password in eleven places. It happens
one reasonable decision at a time. You need it locally, so it goes in a .env.
CI needs it, so it goes in the pipeline settings. The container needs it, so it
goes in the deploy manifest. Terraform creates the database, so it's in state.
A teammate needs it, so it's in a Slack thread. An agent needs to call Stripe,
so it's in the agent's environment.
Each step was correct in isolation. The result is a credential with eleven copies, no owner, and no way to know which of them leaked when it does.
This post walks through the situations that force a secret into existence somewhere, what each situation actually exposes, and then how we think about collapsing them. The short version: every one of these places is somewhere plaintext rests, and the risk of a resting secret is a product of three things — how long it sits there, how many things can read it while it does, and how hard it is to take back.
Local development: the .env file
The first copy is almost always a .env file, and it's the one people worry
about least because it's "just local."
What it exposes: everything running as your user. Not just your app — every
npm install postinstall script, every editor extension, every CLI tool you
brew installed last year, and now every coding agent you let loose on the
repo. An agent grepping for "where is the API key configured" will find it in
under a second, and a persuaded agent will paste it into a tool call just as
fast. The file is also, historically, the single most common thing accidentally
committed to git, where it lives forever in history even after the revert.
Dwell time: months to years. Readers: every process on the machine. Revocation: manual, and you have to remember which copy you're revoking.
CI: pipeline secrets
The second copy goes into the CI system's secret store, because the tests need a database and the deploy step needs the cloud credentials.
CI secret stores are decent at storage and bad at use. The value is injected
into the job's environment, where it's visible to every step, every action or
plugin the job pulls in, and every echo $ENV someone left in a debug commit.
Log masking catches the exact string and nothing else — base64 it, split it,
JSON-encode it, and it's in the log. Pull requests from forks are their own
exposure surface, and the credentials are almost always long-lived, because
rotating them means updating settings in a UI.
The distinctive failure here is breadth: one job's compromise via a malicious
dependency is a compromise of every secret the pipeline holds, because the
pipeline doesn't distinguish between the step that needed the deploy key and
the step that ran npm test.
Runtime: environment variables in containers
The third copy is the one that actually does the work: the running service's environment.
Environment variables feel ephemeral and aren't. They're in docker inspect
for the life of the container. They're in the orchestrator's stored manifest.
They're in /proc/<pid>/environ for anything on the host with the right
permissions. They're inherited by every child process, which means every shell
command your app spawns and every observability agent that captures process
context in a crash report. A GET /debug/env endpoint left over from an
incident is a plaintext dump of the whole set.
Kubernetes Secret objects are the same story with a different name. They're
base64, not encrypted, unless you configured encryption at rest, and they're
readable by anyone with get secrets in the namespace. Most clusters grant
that more widely than anyone intended.
Infrastructure as code: state files
The fourth copy is the one people don't know they have. If Terraform created
the database, the master password is in terraform.tfstate in plaintext.
sensitive = true redacts it from console output and nowhere else. It's in the
state backend, in every plan file anyone saved, and in the output of
terraform show -json.
This one is uniquely bad because state is supposed to be shared. It lives in a bucket the whole platform team can read, it gets pulled onto laptops for debugging, and its access controls were designed around "who may change infrastructure," not "who may read the production password." We wrote about the fix separately: write-only arguments and ephemeral resources, and a provider designed to never write a value into state at all.
Build time: the bundle
The fifth copy is the one that becomes public. Frontend build tools inline
environment variables into the JavaScript they emit. Vite does it for anything
prefixed VITE_, Next.js for NEXT_PUBLIC_, Create React App for
REACT_APP_. A secret that crosses that prefix line is a secret that's now in a
file served to every visitor with a cache header on it.
The prefix rules are documented and they still get crossed constantly, because
the same .env file holds both the public analytics id and the private API key,
and nothing about the file distinguishes them. The failure isn't a leak in the
usual sense. It's a publish.
Third-party platforms: the copy you don't run
The sixth copy is the one you hand to someone else. Vercel, Netlify, Railway, GitHub Actions, Fly — every platform you deploy to has its own environment variable settings, and every one of them is a plaintext copy held by a vendor whose runtime you can't get inside.
The problem here isn't that these platforms are careless. It's that the copy is disconnected. Rotate the key at the source and Vercel still has the old one until someone remembers to paste the new one. Offboard an engineer and you have to audit six dashboards. The credential's lifecycle is now split across systems that don't know about each other, and the weakest of them sets your posture.
Machine to machine: services talking to services
The seventh copy is between your own systems: the API key one service uses to call another, the signing key for your JWTs, the key that encrypts records at rest. These are the credentials with the longest lifetimes in most fleets, because rotating them is a coordinated change across two systems and nobody schedules that.
They also tend to be the highest-authority secrets you have. A leaked JWT signing key is a leaked "log in as anyone." A leaked encryption key turns a database dump into a customer data dump. And they're the ones most likely to be sitting in code, because "it's internal" felt like enough.
Agents: the copy that can be talked into anything
The eighth copy is new, and it changes the problem's shape rather than just adding a place.
A conventional service that holds a key uses it the way you coded it to. An
agent uses it the way it was persuaded to, and the persuasion arrives in the
same channel as the work — a web page it reads, a file in the repo, a tool
result. Give an agent STRIPE_KEY in its environment and you've given it to
whatever text the agent reads next. MCP server configs are .env files with a
different extension; sandboxes get their secrets injected at start and then
run untrusted code with them in scope.
The agent case makes the three axes brutally clear: unlimited readers (every input is a potential reader), no meaningful dwell-time limit, and revocation only after the fact.
People: the copy that never leaves Slack
The ninth copy is human. Onboarding means someone DMs the new engineer the
values. Debugging production means someone pastes a connection string into a
thread. A password manager helps with the human-to-human part and does nothing
for the human-to-machine part, so the values get typed back out into a .env
and we're at copy one again.
This is the one where the compliance auditor's question — "who has had access to this credential, ever?" — has no honest answer, because Slack search is not an access log.
The secrets manager: the copy you hoped would be the last
The tenth copy is the one you adopted to fix the other nine. And it does help: a central store gives you one place to rotate, one access list, one audit trail. But it introduces two things worth being clear-eyed about.
First, it's a single point of failure for everything you deploy. If it's down, nothing boots. If it's gone, nothing boots ever.
Second, and less discussed: most secrets managers can read your secrets. Not "could if compromised" — can, by design, because they hold the keys that encrypt the values. You've replaced eleven copies with one, and then handed that one to a vendor with the policy promise that they won't look.
If your reason for consolidating is "a system that holds this in plaintext will eventually leak it," that reasoning should apply to the consolidator too.
The pattern underneath
Look back across the ten and the same thing is wrong every time: plaintext came to rest somewhere it didn't need to be, stayed longer than the work required, and was readable by more than the one thing doing the work.
That reframes the goal. It's not "put the secrets somewhere safe." It's: make the plaintext exist in exactly one process, for exactly the duration of the work, with a credential you can revoke individually — and make sure nothing upstream of that process, including the store, can read it.
What that looks like in practice
This is the shape seekrit is built around, and here is how it maps to each of the places above. The common mechanism: values are encrypted in your browser or CLI before they're stored, so the server only ever holds ciphertext. Each workload gets a service token bound to one environment, and decryption happens wherever that token is — never on our side.
Local development. No .env. Run the app under the CLI and the environment
exists only in the child process, for the life of the process:
seekrit run -- pnpm dev
Your login session provides the key; the values never touch disk. Coding agents on the machine find no file to read. The dotenv guide covers moving an existing file over.
CI. The pipeline holds one thing: a token for the environment the job
needs, not the secrets themselves. seekrit run resolves them inside the job
step that uses them, so a compromised test step doesn't get the deploy
credentials. Revoke one token and one pipeline loses access; nothing else
notices. See CI/CD.
Containers and clusters. seekrit-run is a small static binary that works
as a container entrypoint: the image receives a token, resolves at start, and
docker inspect shows a revocable token rather than the credentials. For
Kubernetes, the External Secrets Operator chart
resolves and decrypts inside your cluster, so the plaintext never crosses the
cluster boundary in either direction. Nomad gets a native
provider plugin that decrypts on the client node.
Terraform. The provider exposes values only through write-only arguments and ephemeral resources, and a test in the provider's build fails if anyone adds an attribute that could land in state.
Frontend builds. The Vite plugin loads the environment before Vite reads it, keeps the prefix rule as the only line between server and browser, and refuses to widen it — you can hide a prefixed name from the bundle, never expose an unprefixed one.
Third-party platforms. This is the one place the model bends, and we'd
rather say so than pretend. Pushing a value to Vercel requires plaintext at a
moment no client of yours is present, so sync
decrypts in an isolated worker for the duration of a push — opted in per
environment, with a grant computed on your side that the server can't create
alone, and an acknowledgement recorded in the audit log. Use it when the
target runtime gives you no other way in, and prefer seekrit run or the SDKs
when it does.
Machine to machine. Managed keys give you signing and encryption keys that are generated and used client-side, with an AWS KMS-compatible endpoint for tooling that already speaks that API. Rotation handles the coordinated change nobody schedules.
Agents. The agent never holds the key. It holds {{seekrit:STRIPE_KEY}}
and an egress proxy beside it substitutes the real
value on the way out — toward allowed hosts, methods, and paths only, default
deny, with a policy that's signed on your
side so we can't rewrite where your credentials may go either. The same
substitution ships in-process for
frameworks, and the sandbox guides cover the hosted
runtimes.
People. Roles, per-environment access, and temporary access that expires on its own instead of when someone remembers. Every read by a person is in the audit trail. Honey tokens tell you when a copy you thought was gone gets used.
The store itself. Zero-knowledge is the answer to "can the vendor read it" — there's nothing on our side to read. For "what if the vendor is gone," you hold a signed export and an offline decryptor that opens it with no network and no us. We wrote up exactly what you keep if we disappear.
The honest caveats
None of this removes plaintext from the world. Your app has to hold the database password in memory to connect to the database. The point is that it holds it in one process, for the life of that process, and nowhere else — not in a file, not in state, not in an image, not in a vendor's database, and not in an agent's context window.
Some of these tools are weaker boundaries than others, and we try to say which. An in-process shim shares an address space with the caller; the proxy doesn't. Sync is a deliberate exception with a fence around it, not a pattern to extend. A service token is still a credential, and a leaked one still needs revoking — it's just one thing to revoke, bound to one environment, with a record of everything it read.
If you take one thing from the inventory above, make it this: the next time you're about to paste a value somewhere, ask what will be able to read it while it sits there, and for how long. Most of the copies exist because nobody asked.