← Back to Writing
Security DevOps CI/CD

Stop Trusting Yourself to Notice the Leaked Key

August 20, 2025 · 6 min read

Here is something I have made peace with: I am not reliable at spotting a leaked API key in a diff. Neither are you. Neither is the reviewer skimming a 600-line pull request at 5pm on a Friday. Humans are pattern-matchers who get tired and trusting, and "be careful with secrets" is a hope, not a control. The whole premise of this post is that you should stop relying on attention for the things attention is bad at, and move them to machines that never get tired.

The same few classes of mistake show up on project after project. A developer pastes an AWS key into a config file "just to test" and commits it. A Dockerfile runs as root, pulls latest, and bakes a token into a layer. A deploy script breaks the instant a path contains a space, because a variable was not quoted. None of these are exotic, all of them are catchable mechanically, and catching them in code review is too late while catching them in production is a genuinely bad day that can involve rotating credentials at 3am with your heart rate up.

flowchart LR D["git commit"]:::ghost --> H["Pre-commit hooks<br/>secret scan · Hadolint · ShellCheck"] H -->|fail| X["Blocked locally"]:::gold H -->|pass| P["Push"] P --> CI["CI re-runs same gates"] CI -->|fail| B["Build blocked"]:::gold CI -->|pass| M["Merge"]:::accent

The gates

Three checks earn their place on every project. Secret scanning blocks any commit carrying credentials. It works by combining known key patterns (an AWS key has a recognizable shape) with high-entropy string detection (random-looking blobs are suspicious), plus an allowlist so the inevitable false positives, a test fixture or a sample key in docs, do not train people to ignore it. Hadolint lints Dockerfiles against best practices: pin your base images, do not run as root, do not leak build secrets into layers. ShellCheck catches the shell-script footguns that bite at the worst time: unquoted variables, unset-variable hazards, commands that fail silently. Together they cover a large fraction of the security and reliability mistakes that actually happen in the wild, as opposed to the exotic ones that get conference talks.

Run them twice

The non-obvious rule is to run the same checks in two places. Pre-commit hooks give instant local feedback: the developer learns about the leaked key before it ever leaves their machine, which is the cheapest possible place to fix it, because nothing has been pushed, shared, or logged yet. But local hooks can be bypassed. The --no-verify flag exists, people in a hurry use it, and a hook only runs if someone bothered to install it in the first place. So the identical checks run again in CI, where they are fail-closed and nobody can skip them. Local hooks are for speed and developer experience; CI is the backstop that actually enforces the rule. Neither alone is enough, and that is the point.

Defense in depth

No single gate is the whole answer. They stack, and the reason they stack is that a miss in one layer is caught by the next.

flowchart TD DEV["Developer commits"]:::ghost --> L1["Layer 1: pre-commit hook<br/>(local, fast)"] L1 -->|bypassed with --no-verify| L2["Layer 2: CI gate<br/>(fail-closed, unskippable)"] L2 -->|somehow slips| L3["Layer 3: secrets manager<br/>secret never committed, small blast radius"]:::accent

That third layer is the quiet hero. If secrets live in a secrets manager and are injected at runtime, never written into the repo, the image, or the CI logs, then even a slip that gets past both gates is not catastrophic, because the thing that leaked was a reference, not the secret itself. And I keep tools and automated agents away from sensitive files like .env by default. I have built tooling around exactly that idea, because the fastest way to leak a secret today is to let an automated process read a file it never needed to see and then echo it somewhere you cannot take back.

When a secret leaks anyway

Gates reduce the odds, they do not get them to zero, so you decide the response in advance instead of improvising it at 3am. The rule is simple and absolute: a secret that has touched git history is compromised, full stop. You do not quietly delete the commit and hope. You rotate the credential, because rewriting history does not un-clone the repo from whoever already had it, and scanners that crawl public and private repos are fast. A gate that catches the key before the push saves you this entire ordeal, which is the real return on the whole setup: not that mistakes never happen, but that the expensive ones happen far less often.

What gates do not catch

It is worth being honest about the boundary. These checks catch a specific, common, mechanical class of problem. They do not catch a logic flaw that leaks data through an API, a missing authorization check, or a dependency with a known vulnerability. Secret scanning, Hadolint, and ShellCheck are necessary, not sufficient, and treating a green pipeline as "secure" is its own mistake. They are the floor, the things no project should ship without, and the rest of your security work sits on top of that floor rather than being replaced by it.

Rolling it out without a revolt

Introducing gates to a team that does not have them is a change-management problem as much as a technical one, and the wrong rollout gets the whole thing disabled within a week. You do not flip every check to blocking on day one across a large existing codebase, because you will surface hundreds of pre-existing issues and bury everyone in noise, and noise is how a gate earns a reputation as the thing that wastes everyone's afternoon. Instead you start in a warn-only mode, fix or allowlist the existing findings deliberately, and only then switch the checks to fail-closed once the baseline is clean. You make the hooks trivial to install, ideally automatic on checkout, so nobody has to remember. And you tune aggressively in the first weeks, because the goal is a gate developers trust and barely notice, not one they fight, and the difference between those two outcomes is almost entirely about how carefully you introduced it.

The tradeoffs, and the culture

Gates have a cost. False positives create friction, and friction is how you end up with developers who disable the gate, so you tune the rules, keep the checks fast, and treat a false positive as a bug in the gate rather than a thing to grumble past. Speed matters more than it sounds: a pre-commit hook that takes ten seconds is a hook people keep, and one that takes two minutes is one they rip out. The deeper point is cultural. Enforced gates beat written guidelines every single time, because guidelines get skimmed and forgotten while a fail-closed check is just there, every commit, never tired and never in a hurry. The job is not to make people more careful. It is to make the easy path and the safe path the same path, so that doing the right thing requires no willpower at all.

Enjoyed this? Let me know

0claps