Field Guide · 04 of 10
Why AI agents hard-code API keys
Told to make it work, an agent puts the key on the line that needs it and leaves a TODO to move it later. Nobody moves it. The key goes into the commit, then the bundle, then the browser of everyone who opens the page. Deleting it afterwards undoes none of that: a committed key is dealt with by revoking it.
What is actually happening
The task is “make the request succeed”. The request needs a key, and the key is in front of the agent: in your prompt, in a pasted snippet, in a file it read. The shortest path is to put the value where it is used. Reading it from the environment needs a variable name, a place to set it, and you.
In the skit the line carries // TODO: move to env. It is a promise made by something with no next session in which to keep it. The comment stays, the key stays, and a skimming reviewer sees a note saying someone already thought about it.
Frameworks that build for the browser have a rule about which environment variables reach it. In Next.js the prefix is NEXT_PUBLIC_; in Vite it is VITE_. A variable with the prefix is inlined into the client JavaScript wherever it is read, by design. So an agent that hits “undefined in the browser” and renames a secret to NEXT_PUBLIC_… has fixed the error by publishing the secret.
Then there is history. Once a key is committed, deleting the line in the next commit removes it from the current files and from nothing else. Every clone and fork that already has it keeps it. Treat the key as published: revoke it at the provider and issue a new one. Tidying history is optional and comes after.
How it shows up in the diff
- A string literal shaped like a key or token, often with a recognisable prefix, sitting on a line that used to read from the environment.
- A
.envfile among the changed files. - An edited
.gitignore, particularly one that loses a line. - A secret-looking name behind a client-exposed prefix, such as
VITE_PAYMENTS_SECRET.
// src/checkout.ts
- const key = process.env.PAYMENTS_KEY
+ const key = "sk-live-4f9a2c81e07d5b36c1d0" // TODO: move to env
# .env
+ VITE_PAYMENTS_SECRET="sk-live-4f9a2c81e07d5b36c1d0"
The key is made up. Real ones look much the same, which is why the patterns below match both spellings.
Guardrail
Say what to do instead, or the agent has nowhere to put the value.
Paste into AGENTS.md
Never write a secret into code, config, tests or examples. Read it from the environment and tell me which variable to set.
Check it mechanically
# added lines that look like secrets; -i covers variable-name casing (and lets AKIA match lowercase, harmlessly)
git diff -U0 origin/main...HEAD | grep -niE '^\+.*(sk[_-]live[_-]|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY|(api[_-]?key|secret|token|password)["'\'']?[[:space:]]*[:=][[:space:]]*["'\''][^"'\'']{8,})'
# changed .env or .gitignore files
git diff --name-only origin/main...HEAD | grep -E '(^|/)(\.env|\.gitignore)'
# after the build, against whatever folder you deploy:
grep -rnE 'sk[_-]live[_-]|AKIA[0-9A-Z]{16}' dist/
The first command reads only added lines and matches known prefixes, private-key headers, and a key-like name assigned a quoted string of eight characters or more. It does not match const apiKey = process.env.API_KEY. The number in front of each hit is its line in the diff output, not in the file, and no file name is printed; search for the text to find it. The second lists those files (.env.example matches too). The third looks in the built output, where a key that was a string in the source is still a string.
grep exits 0 when it prints a match and 1 when it finds nothing, the reverse of what a gate wants. To fail a CI job on a match, wrap the command: if … | grep -q …; then exit 1; fi. Do not negate it with !; a shell running with -e does not stop on a negated pipeline.
These patterns are a sample. They miss a key with no known prefix in an oddly named variable, and they flag test fixtures. Run a dedicated secret scanner in CI and as a pre-commit hook. It knows far more key formats, and the hook stops the key before it reaches history, the only moment removal still works. A hook can be skipped, so CI is the one that counts.
Further reading
Alice and Bob Learn Application Security: secrets, keys and their storage, explained for people who ship software and do not do security for a living.