Environment Variables an Agent Must Never Commit
Sasha Reid
September 30, 2026
The agent did not commit the Stripe secret key. I want to be precise about that, because it is the reason this story is worth telling. The .env file was in .gitignore. The repository was clean. A secret scanner would have found nothing.
What the agent did was fix a build error. A client component on the checkout page needed to know which Stripe account to talk to, and it was reading process.env.STRIPE_SECRET_KEY, which was undefined in the browser. The framework only exposes variables to client code if their names start with a public prefix. So the agent renamed the variable to NEXT_PUBLIC_STRIPE_SECRET_KEY, updated the .env file, updated the hosting dashboard through the CLI, and the build went green.
From that deploy onward, the live secret key for a small subscription business was sitting in a JavaScript bundle served to every visitor. Anyone who opened developer tools could read it. It was there for six days before a routine look at the bundle size made me search the built files for sk_live.
Nothing was committed. Everything was exposed.
I tell that story first because “never commit your secrets” has become one of those rules everyone agrees with and nobody examines. With coding agents in the loop, the rule is too narrow. The real rule is closer to: certain values must never leave the server’s environment, by any path, and an agent has more paths than a person does.
The values that must never leave the server
Here is the list I now put, almost verbatim, into the instructions file of every project where an agent can write code. It is not about file names. It is about what the value can do if someone else has it.
- Database connection strings with passwords.
DATABASE_URL,POSTGRES_PASSWORD,REDIS_URLwith credentials, anything that lets someone connect to your data directly. - Payment secret keys and restricted keys. Stripe
sk_live_andrk_live_, and their equivalents on other providers. - Webhook signing secrets. With these, someone can forge a “payment succeeded” event your app will trust.
- Session, cookie, and JWT signing secrets.
SESSION_SECRET,NEXTAUTH_SECRET,JWT_SECRET. With these, someone can mint a valid login as any user, including you. - OAuth client secrets. The client ID is public. The secret is not.
- Cloud and storage access keys. AWS keys, R2 and S3 credentials, GCP service-account JSON files. These tend to have far broader permissions than the app needs.
- Email and SMS credentials. SMTP passwords, SendGrid or Postmark server tokens, Twilio auth tokens. Leaked mail credentials get used for spam within hours, and your domain’s reputation pays for it.
- AI provider API keys. Including, ironically, the one the agent itself might be using. Leaked model keys get drained quickly.
- Encryption keys for anything you encrypt at rest.
And the short list of values that are designed to be public and are fine in client code or a committed file: publishable payment keys (pk_live_), OAuth client IDs, a public site URL, analytics site IDs, and feature toggles that do not gate anything sensitive. If the provider’s documentation calls it “publishable” or “public,” it is meant to be seen. If it does not, assume it is not.

The paths a secret takes out of the environment
After the Stripe incident I went through every agent-assisted repository I help maintain, looking for any route by which one of those values could leave. I found seven. Only one of them is the classic “committed .env file.”
1. The committed .env. Still the most common. It usually happens at the very start: the agent scaffolds a project, creates a .env with placeholder values, and you fill in real values before the .gitignore exists or before it contains the right pattern. Many frameworks use several files, like .env.local, .env.production, and .env.development.local, and a .gitignore that only lists .env misses the rest.
2. The public prefix. My Stripe story. NEXT_PUBLIC_, VITE_, PUBLIC_, REACT_APP_, EXPO_PUBLIC_: each framework has a prefix that means “bake this into the client bundle.” An agent under pressure to make a build pass will reach for it, because it is the documented fix for “variable undefined in the browser.” The correct fix is almost always to move the code that needs the secret to the server.
3. The helpful .env.example. The agent is asked to document required variables and copies the real .env into .env.example “so it is complete.” That file is meant to be committed. I have seen this twice.
4. Inline values in config files. docker-compose.yml with environment: POSTGRES_PASSWORD: hunter2. A next.config.js or vite.config.ts with a key pasted into the env block. A GitHub Actions workflow with a token in plain text instead of a reference to a repository secret. The agent sees an error, finds that hard-coding the value fixes it, and moves on.
5. Test fixtures and seed scripts. A test needs to hit the real payment sandbox, so the agent puts a key in tests/fixtures/config.json. Sandbox keys are less dangerous than live ones, but they still identify your account, and I have seen live keys end up there because the developer only had one set configured.
6. Logs and error messages. The agent adds debug logging while chasing a connection problem: console.log('connecting with', process.env.DATABASE_URL). It works, the bug is fixed, and the log line stays. Now the password is in your hosting provider’s log viewer, your error tracker, and anywhere logs are forwarded.
7. Build output and source maps. Server-side code that gets accidentally bundled into a client chunk, or a source map uploaded publicly that contains server files. Less common, but it is how secrets show up in places nobody thought to look.
What actually stops it
No single control covers all seven paths, so I layer a few cheap ones.
A .gitignore written before the first real value exists. I use .env* followed by !.env.example, which ignores every variant and explicitly allows the example file. I make this the very first commit, before the agent writes anything.
A pre-commit secret scanner. Tools like gitleaks or trufflehog run in a pre-commit hook and in CI. They catch key-shaped strings in any file, not just .env, which covers the fixture, config, and example-file paths. GitHub’s push protection does something similar on the server side for many common providers. Neither is perfect, but together they catch most of paths 1, 3, 4, and 5.
A bundle check in CI. After the build, search the client output for known secret prefixes: sk_live, rk_live, whsec_, AKIA, -----BEGIN, and anything else your providers use. Fail the build if any match. This is the control that would have caught my Stripe key on the first deploy instead of the sixth day. It took ten lines of shell.
A lint rule on public prefixes. A simple rule that fails if any variable with a public prefix also contains SECRET, PRIVATE, PASSWORD, or TOKEN in its name. Crude, and it would have caught the rename instantly.
Instructions the agent actually reads. I put the list from the top of this article into the project’s agent instructions with three explicit rules: never add a public prefix to a variable that holds a secret; never log environment variable values; never hard-code a credential to fix an error, stop and ask instead. Since adding that paragraph, I have watched the agent stop and say “this needs the secret key on the server; I’ll move the Stripe call into an API route” rather than reaching for the prefix. It knew the right answer all along. It needed permission to take the slower path.

When one gets out anyway
It will, eventually. Here is the order I follow, and it is the same whether the leak was a commit, a bundle, or a log line.
- Rotate first. Generate a new secret at the provider, update the server environment, redeploy, and revoke the old one. Do this before anything else. Until the old value is revoked, it works for anyone who has it.
- Check usage. Most providers show recent API activity per key. For Stripe, I checked for any requests from IPs that were not our servers during the six days. There were none, which I attribute to luck rather than obscurity.
- Clean up the exposure: purge the log lines, redeploy the fixed bundle, and if it was committed, remove it from history. But understand that rewriting Git history is housekeeping, not remediation. Anyone who cloned, forked, or scraped the repository already has the value. Rotation is the fix.
- Add the control that would have caught it. Every leak I have dealt with added one line to a CI check or one sentence to the agent instructions.
The broader point
An agent is very good at making errors go away. That is most of its value. But “variable undefined,” “connection refused,” and “unauthorized” are errors whose fastest fix is often to move a secret somewhere it should not be. A person usually feels a small twinge before pasting a live key into a config file. The agent does not feel anything; it sees a red build turn green.
So the job is to make the secure path the obvious one: server-only secrets, clear instructions, scanners that fail loudly, and a bundle check that assumes the worst. None of it is expensive. All of it is cheaper than explaining to a customer why their card details page was talking to Stripe with a key the whole internet could read.