Secrets Don't Belong in Repos (Even Private Ones)
A hardcoded API key feels harmless right up until it isn't. How secrets leak through repos, logs, and error trackers — and the habits that keep them out.
A few years back, I watched a team spend an entire weekend rotating credentials because of a single line of code. Someone had committed a payment provider key to a private repo "just to test something," and six months later that repo got cloned onto a contractor's laptop, which got stolen. Nobody did anything malicious, as far as we know. But as far as we know is not a phrase you want in your incident report, so every key that laptop could have touched got rotated. That's the thing about secrets in code: the cost isn't the commit, it's the uncertainty forever after.
I want to walk through how secrets actually end up in the wrong places, because it's rarely someone typing a password into a README. It's subtler than that, and the subtle paths are the ones code review needs to catch.
"It's a private repo" is not a boundary
The most common pushback I hear is that the repo is private, so who cares? Here's how I think about it from the attacker's side: a private repo is a single access-control decision standing between your production credentials and everyone who will ever have read access to that repo. That includes every current and future employee, every contractor, every CI runner, every laptop with a clone, every backup of those laptops, and every third-party integration you've granted repo access to.
And git makes it worse, because git remembers. Deleting a secret in a follow-up commit removes it from the current tree, not from history. Anyone who clones the repo gets every version of every file you've ever committed. If a secret has ever been committed, the honest posture is: it's burned. Rotate it. Rewriting history is a cleanup step, not a remediation.
# This key is now in git history forever,
# in every clone, on every laptop.
STRIPE_KEY = "sk_live_..."
client = stripe.Client(api_key=STRIPE_KEY)
The fix is boring and that's the point:
import os
STRIPE_KEY = os.environ["STRIPE_KEY"] # fails loudly if missing
client = stripe.Client(api_key=STRIPE_KEY)
Note the os.environ["..."] rather than os.environ.get(...) with a default. I want the app to crash at boot if the secret is missing, not limp along with an empty string and produce a confusing auth error three layers deep.
The leak paths nobody reviews for
Hardcoded keys are the obvious case. The leaks that actually bite teams are quieter.
Logs
Somewhere in your codebase there's a line like logger.info("Calling upstream", request: req.to_h). If that request hash includes an Authorization header, your bearer token is now in your log aggregator, searchable by everyone with log access, retained for however long your retention policy says. Log access is almost always broader than secret access. That asymmetry is the vulnerability.
When I review logging code, I ask one question: could this object ever contain a credential, a session token, or a password? If the answer is "not right now, but the struct could grow one," I still flag it. Log allow-lists (log these specific fields) age much better than deny-lists (log everything except these fields), because new sensitive fields get added by people who've never read your logging code.
Error trackers
Error trackers are logs with better marketing. When an exception fires, most SDKs happily serialize local variables, request bodies, and environment context into the event. If your worker crashes while holding a decrypted API key in a local variable, that key may now live in a third-party SaaS. Most trackers have scrubbing config — before_send hooks, field filters. Someone on your team should own that config and it should be tested, not just configured once and trusted.
The .env file that escaped
.env files are fine as a local development pattern. The failure mode is the .gitignore entry that covers .env but not .env.production, or the Docker image that COPY . .'s the whole directory, .env included. Build contexts and image layers are repos too, in the sense that matters: they get pushed somewhere, cached somewhere, pulled by someone.
What good looks like
The pattern I push teams toward has three layers, and none of them are exotic.
Config comes from the environment (or a secrets manager). Code references names, never values. In production, values come from your platform's secret store — injected at runtime, encrypted at rest, access-logged. The moment your app can say where a secret came from, you can reason about who can read it.
Secrets are rotatable, and you've actually rotated one. Rotation you've never rehearsed is rotation you don't have. The first time a team rotates a database credential should not be during an incident at 2 a.m. Do it once on a calm Tuesday, write down what broke, fix that, and now rotation is a lever you can pull instead of a research project.
Detection runs before merge. Secret scanners (gitleaks, trufflehog, GitHub's push protection — pick one) catch the obvious patterns: high-entropy strings, known key formats. They're not perfect, and they'll never catch a password that looks like a word, but they turn the most common mistake into a failed CI check instead of a rotation weekend.
A note on generated code
One pattern I've started seeing in review: AI-assisted code that wires up a third-party client with a placeholder literal — api_key="your-api-key-here" or worse, a realistic-looking example key copied from training data. It compiles, it's plausible, and a developer in a hurry replaces the placeholder with the real key in place because that's where the assistant put it. The generated code taught them the wrong shape. When you review AI-written integration code, check the configuration plumbing first: does the secret come from the environment, or did the scaffold quietly assume a literal? The assistant doesn't know your secrets policy. The reviewer has to be the one who does.
The review questions I always ask
When a diff touches credentials, config, or logging, I run through a short list:
- Is any value in this diff a secret, and if so, why is it a value and not a reference to one?
- If this secret had been committed, even briefly, has it been rotated — not just removed?
- Can anything logged or sent to the error tracker here ever contain a token, password, or key?
- Does the app fail loudly at startup when a required secret is missing?
- Is there a scanner in CI that would have caught this before I did?
None of this is glamorous. But secrets hygiene is one of those areas where the boring, consistent habit — environment-driven config, aggressive log scrubbing, rehearsed rotation, scanning in CI — quietly removes an entire category of very bad weekends. I'll take boring.