Start from the right assumption: your coding assistant will try to hardcode a secret. Not out of carelessness, but because inlining the key is the shortest path to code that runs, and running code is what it optimizes for. So the goal is not to convince the model to behave. The goal is to build a workflow where a hardcoded key hits a wall before it becomes a commit, and another wall before it becomes a push. Four layers, each one catching what the last one missed.
Layer 1: environment variables from the first commit
The foundation is that real secret values live in a file the repository never sees. Set this up before you write a line of app code, because retrofitting it after a key is already committed means cleaning git history too.
# .gitignore (add this first, before the first commit) .env .env.local # .env (real values, never committed) STRIPE_SECRET_KEY="sk_live_..." DATABASE_URL="postgres://..." # .env.example (committed, so the AI learns the shape, not the secret) STRIPE_SECRET_KEY="" DATABASE_URL=""
The .env.example is the quiet trick. It gives the assistant the variable names it needs to write correct code (process.env.STRIPE_SECRET_KEY) without ever putting the real value in its context. Confirm .env is gitignored before you save a single real secret into it.
Want to see exactly what Prbl flags? Watch it scan a demo app, no repo or account needed.
See a live scan →Layer 2: tell the assistant, in the file it actually reads
Every major tool now reads a project instruction file. Put an explicit rule about secrets in the one your tool uses, so the guidance is in context on every request instead of something you hope it remembers:
- Cursor:
.cursor/rulesorAGENTS.md - GitHub Copilot:
.github/copilot-instructions.md - Claude Code:
CLAUDE.md - Codex:
AGENTS.md
The rule itself can be short and blunt:
## Secrets Never write API keys, tokens, passwords, or connection strings as literal values in source. Always read them from environment variables (process.env.NAME or import.meta.env.NAME). If a value is missing, reference the variable and add it to .env.example, never inline it.
Be honest with yourself about what this buys you: it lowers how often the assistant reaches for an inline key. It does not eliminate it. This is the layer people over-trust, which is exactly why it is not the last one.
Layer 3: a pre-commit hook that blocks the commit
This is the layer that actually holds, because it does not depend on anyone, human or model, choosing to do the right thing. A secret scanner runs on every commit and fails it when a key is present, so the secret cannot enter your history in the first place.
# install gitleaks
brew install gitleaks
# .git/hooks/pre-commit (make it executable: chmod +x)
#!/bin/sh
gitleaks protect --staged --redact --verbose || {
echo "Commit blocked: a secret was detected in staged changes."
exit 1
}Now when the assistant inlines a key and you go to commit, the commit fails with the file and line. You move the value to .env, and only then does the commit go through. The mistake still happens; it just never ships.
Layer 4: a server-side backstop
The last net catches the case where someone clones the repo without your hooks, or bypasses them. Turn on your host's server-side secret scanning. On GitHub, enable secret scanning with push protection, which rejects a push that contains a recognized key format, and is free on public repositories. It covers a narrower set of secrets than your local hook, which is why it is the backstop and not the primary defense.
Layer 3 and Layer 4 together mean a hardcoded secret has to defeat a local gate and a remote gate to reach a place anyone can read it. Compared with the default of zero gates, that is the whole game.
Then check what already shipped
Prevention protects new commits. It does nothing about the keys already sitting in code you shipped last month, and if you have been building with an AI assistant without these layers, assume there are some. A scan reads the repo the way an attacker's bot does and hands you the file and line for anything already exposed, so you can rotate it before someone else finds it. We have scanned nearly 2,000 AI-built apps and a hardcoded secret was the top finding in every corpus; the odds that a repo built without these guards is clean are not good.
Frequently asked questions
Can't I just tell the AI not to hardcode secrets?
You can, and you should, but it is the weakest of the four layers. An instruction file lowers how often the assistant inlines a key; it does not stop it. The model optimizes for code that runs, and pasting the key inline runs. Treat the instruction file as prevention that reduces frequency, and the pre-commit hook as the gate that actually blocks what slips through.
Do I need a pre-commit hook if GitHub already has secret scanning?
Run both. GitHub's push protection is a server-side backstop that fires after you have already committed the secret locally, and it only covers recognized key formats. A local pre-commit hook stops the secret before it ever enters a commit, catches custom and generic secrets, and works on private repos and other hosts. Two gates beat one.
The key is in my frontend code. Isn't that always public anyway?
Yes. Anything shipped to the browser is readable by anyone, no matter how you load it, so a frontend API key is only safe if the service behind it enforces its own permissions. For a database, that means row level security configured on every table. Environment variables keep a key out of your git history; they do not make a browser-exposed key private.
What about secrets the AI needs to see to write working code?
It does not need the real value. It needs the variable name. Give it a committed .env.example with empty or placeholder values so it learns the shape of your config, and keep the real .env gitignored. The assistant writes process.env.STRIPE_KEY correctly without ever seeing the actual key.
The setup in four commands
- Gitignore
.envand commit a blank.env.examplebefore writing app code. - Add a secrets rule to your tool's instruction file (
CLAUDE.md,.github/copilot-instructions.md,.cursor/rules, orAGENTS.md). - Install gitleaks as a pre-commit hook so a hardcoded key fails the commit.
- Enable GitHub push protection as the server-side backstop.
None of it depends on you or the model being careful, which is the point. The assistant can keep reaching for the inline key. It just won't get one to the repo.