Pre-commit hook to block committed secrets
The cheapest secret leak to prevent is the one a pre-commit hook catches before git commit finishes. The catch: a local hook can be bypassed, so it needs a CI backstop. This page sets up both, extending CI/CD Config Validation.
What makes this problem different from ordinary linting is that the failure is irreversible. A style violation that reaches the default branch is fixed by a follow-up commit; a credential that reaches the default branch is compromised from that moment, and no subsequent commit undoes it. Everything about the design below follows from that asymmetry — the local hook exists for speed, the CI job exists because the local hook is optional, and both exist because the cost of one miss is a rotation rather than a revert.
Problem 1: relying on memory
# ANTI-PATTERN: "I'll remember not to commit .env" — until you don't
# git add . && git commit -m "wip" # .env with live keys is now in history
A single git add . is all it takes; rotating the leaked credential is the only fix once it is in history. The mistake is rarely carelessness in the moment — it is the combination of a broad git add, a file that is untracked for a good reason, and a .gitignore that has one entry too few. .env is usually covered; .env.local, credentials.json, id_rsa, and a dump.sql containing a connection string are the ones that slip through.
Relying on the ignore file alone has a second weakness: it only helps for files nobody has staged deliberately. A credential pasted into a source file, a test fixture, a Jupyter notebook output cell, or a README example is tracked content, so no ignore rule applies. A scanner reads content rather than filenames, which is why it catches what the ignore file cannot.
Problem 2: a hook with no CI backstop
# ANTI-PATTERN: local hook only — trivially skipped
git commit --no-verify # bypasses every local pre-commit hook
Local hooks are advisory; --no-verify skips them. CI must enforce the same scan. This is not usually adversarial: --no-verify gets used to get past an unrelated failing hook during a hurried fix, and the secret rides along in the same commit. A hook that is slow or noisy makes that far more likely, which is one more reason to keep the scanner’s false-positive rate low.
The other reason the local hook cannot stand alone is that it only exists where someone ran pre-commit install. A new contributor who clones and commits has no hooks at all until they run that command, and nothing in git prompts them to. The CI job covers everyone, including the person who cloned an hour ago and has not read the README yet, which is exactly the person most likely to commit a credential by accident.
Secure implementation
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks # scans staged changes for secrets
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
# one-time setup
pip install pre-commit detect-secrets
detect-secrets scan > .secrets.baseline # record known-safe matches
pre-commit install # activate the local hook
# .github/workflows/secrets.yml — the backstop that cannot be skipped
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: gitleaks detect --no-banner # fails the build on any finding
The local hook gives instant feedback; the CI job is the gate that --no-verify cannot bypass. The baseline records intentional matches (test fixtures) so they do not block every commit. Running both scanners is deliberate rather than redundant: they detect differently, and the overlap is where the confidence comes from.
gitleaks is primarily rule-based — a large set of patterns matching the recognisable shapes of real credentials, such as an AWS access key ID, a Stripe live key, or a GitHub token prefix. That makes it precise: a hit is usually a real credential of a known type, and false positives are rare. detect-secrets adds entropy-based detection, flagging high-randomness strings that match no known format. That catches the internal token, the generated password, and the private key from a service nobody wrote a rule for — at the cost of occasionally flagging a hash, a UUID, or a base64 test fixture.
Together they cover both halves of the problem: known credential formats and unknown-but-random-looking strings. Running only the rule-based scanner misses your own service’s tokens; running only the entropy scanner produces enough noise that people stop reading it.
Living with the baseline
The .secrets.baseline file is what keeps the entropy scanner usable, and it is also the part most likely to decay into a rubber stamp. It records every currently-known match with a hash and a location, so those matches stop blocking commits while anything new still does.
# a legitimate new fixture appeared — audit it, then update the baseline
detect-secrets scan --baseline .secrets.baseline # refresh in place
detect-secrets audit .secrets.baseline # mark each entry true/false
git add .secrets.baseline
The audit step is the one people skip, and skipping it is what turns the baseline into a liability. Auditing walks each entry interactively and asks whether it is a real secret, recording the answer in the file. A baseline full of unaudited entries is a list of things nobody looked at, which is indistinguishable from a list of things nobody noticed were real.
Two habits keep it honest. Review baseline changes in code review like any other diff — a pull request that adds three baseline entries deserves the same scrutiny as one that adds three # noqa comments. And regenerate rather than edit: hand-editing the JSON to silence a warning is fast, and it removes the record of what was actually checked while leaving a file that still looks authoritative to the next reader.
There is also a filesystem-level defence that costs nothing and complements both scanners. Adding .env, *.pem, *.key, and id_rsa to .gitignore prevents the accidental git add . in the first place, and adding the same patterns to .dockerignore prevents the parallel accident of baking them into an image. The scanner is the safety net; the ignore files are the thing that means the net is rarely tested.
What to do when the hook fires on a real secret
The hook blocking a commit is the success case, but the minutes after it fires are where people make the situation worse. The failure mode is treating it as a git problem — amend, force-push, move on — when the first question is whether the value was ever transmitted anywhere.
If the secret is still only in the working tree and staged changes, nothing has leaked. Remove it from the file, put the real value wherever it belongs — a secret manager, an untracked .env, a CI variable — and commit. No rotation is needed because no commit ever existed.
If it made it into a local commit but was never pushed, it exists only on that machine. git reset --soft back past the commit, remove the value, and recommit. Still no rotation, provided you are confident nothing pushed the branch — a background IDE sync or an auto-push hook counts as pushing.
If it was pushed anywhere at all, treat the credential as compromised and rotate it now. That holds even for a deleted branch on a private repository: the object existed on the remote, it may be in a fork, a mirror, a CI cache, a backup, or a security scanner’s index, and the effort to establish that it was not is larger than the effort to rotate. Rotating first also has the useful property of making the remaining cleanup unhurried — once the old value is dead, purging history is housekeeping rather than an emergency.
# after rotating: purge the value from history, then force every clone to refresh
pip install git-filter-repo
git filter-repo --replace-text <(echo "OLD_SECRET_VALUE==>REDACTED")
# everyone re-clones; old clones still hold the original objects
The comment on that last line is the part worth internalising. History rewriting changes the repository you push to; it does not reach into anyone’s existing clone, nor into any system that already fetched the objects. That is precisely why rotation is the containment step and rewriting is the cleanup step, and why doing them in the other order leaves a live credential in circulation while everyone waits for a rebase.
Gotchas & version-specific behaviour
- A leaked secret must be rotated, not just removed — history is forever.
detect-secretsneeds a baseline; regenerate it when adding legitimate fixtures.gitleaks detectwithfetch-depth: 0scans full history in CI; the pre-commit hook scans staged changes only.- Keep
.envin.gitignoreas the first line of defence; the scanner is the second. - Pin hook versions with
rev:and update them deliberately —pre-commit autoupdatepulls new detection rules, which can surface findings in code that has not changed. - Notebook outputs are scanned as content, so a printed token in a committed
.ipynbis a real finding, not a false positive. - The pre-commit hook sees staged content, so a secret in a file you have edited but not staged will not be caught until you stage it.
The version-pinning bullet has a consequence worth planning for. Running pre-commit autoupdate brings in new detection rules, and new rules routinely flag credential formats that existed in the repository long before the update — which arrives as a wall of failures on a commit that touched something unrelated. That is a good outcome badly timed. Updating hook versions in a dedicated pull request, where the only change is the rev: bumps and the resulting findings, keeps the noise separated from feature work and means the findings actually get triaged instead of baselined in bulk to unblock a release.
The rotation point deserves the emphasis it gets, because the instinct under pressure runs the other way. Someone notices the secret in a pull request, force-pushes a cleaned branch, and considers it handled — but if the original commit was ever pushed, it existed on the remote, and the value must be treated as compromised regardless of what the branch looks like now. Rotate first, so the exposure is closed; clean history afterwards, so the value is not re-leaked by an old clone.
Production parity checklist
pre-commit installis run by every developer (document it in the README).- The same scanner runs in CI with full history.
.envand*.pemare gitignored.- A
.secrets.baselineis committed and kept current. - Any real hit triggers immediate credential rotation.
- Baseline changes are reviewed in pull requests, not merged unread.
Making pre-commit install automatic removes the weakest item on that list. A make setup target, a uv sync post-step, or a short bootstrap script that developers run once anyway can install the hooks as a side effect, so nobody has to remember a command that only matters the first time. The CI backstop still covers whoever skips it, but a hook that is installed by default is a hook that catches things before the push rather than after.
Key takeaways
Pair a local pre-commit scanner with a CI job that cannot be skipped, and committed secrets are caught before they become permanent. Run both a rule-based and an entropy-based detector so known credential formats and your own unrecognised tokens are both covered, keep the baseline audited so it records decisions rather than silence, and treat any real hit as a rotation rather than a revert. The local hook is for speed and the CI job is for coverage; neither replaces the other, and the ignore files underneath both are what keep either from being tested often. If you are setting this up from scratch, the order that gets you protected fastest is: add the ignore patterns, add the CI scan with full history — which will tell you immediately whether there is already something to rotate — and only then add the local hooks and the baseline. Starting with the local hook feels natural and leaves the existing history unexamined, which is where the credential you most need to know about is likely to be. This is the local half of CI/CD Config Validation.