seekrit
← all posts

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.