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.

Cost of catching a secret at each point Catching a secret at commit time costs seconds, catching it in CI costs a rewrite, and catching it after a push means the credential must be rotated. the same mistake, three very different costs at commit hook blocks it nothing recorded cost: seconds at CI, unpushed local history only amend or rebase cost: minutes after a push every clone has it rotate, then purge cost: an incident
The gradient is steep and one-directional, which is why two overlapping checks are worth the setup.

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.

Why the local hook needs a CI backstop The local hook is fast but optional and skippable, while the CI job is slower but applies to every commit including those from contributors who never installed hooks. local hook instant feedback, no push needed blocks before history exists skipped by --no-verify absent until pre-commit install CI job applies to every commit cannot be bypassed locally runs after the push a hit already means rotation
Each covers the other's blind spot: the hook is fast but optional, the job is late but universal.

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.

Rule-based and entropy-based detection cover different secrets Rule-based scanning finds known credential formats with few false positives, while entropy-based scanning finds unknown high-randomness strings at the cost of more noise. rule-based (gitleaks) AKIA…, sk_live_…, ghp_… known formats, precise few false positives blind to your own token format entropy (detect-secrets) any high-randomness string no format required catches unknown credentials flags hashes and UUIDs too
Neither approach subsumes the other, which is why the two hooks are worth running together.

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.

The baseline lifecycle A new high-entropy string is detected, the developer audits whether it is a real secret, and only audited entries enter the committed baseline. new match commit blocked audit real secret? yes → rotate and remove never add it to the baseline no → record as audited reviewed in the pull request
An unaudited baseline entry is indistinguishable from a secret nobody noticed — the audit step is the whole point.

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.

Response depends on how far the secret travelled A secret still in the working tree needs only editing, one in an unpushed commit needs a reset, and one that was pushed requires rotating the credential before any history cleanup. one question decides everything: was it pushed? working tree edit the file no commit existed no rotation local commit git reset --soft never left the machine no rotation, if certain pushed rotate the credential first then purge history assume it is compromised
Only the third column is an incident — and in that column, rotation comes before anything involving git.

Gotchas & version-specific behaviour

  • A leaked secret must be rotated, not just removed — history is forever.
  • detect-secrets needs a baseline; regenerate it when adding legitimate fixtures.
  • gitleaks detect with fetch-depth: 0 scans full history in CI; the pre-commit hook scans staged changes only.
  • Keep .env in .gitignore as the first line of defence; the scanner is the second.
  • Pin hook versions with rev: and update them deliberately — pre-commit autoupdate pulls 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 .ipynb is 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 install is run by every developer (document it in the README).
  • The same scanner runs in CI with full history.
  • .env and *.pem are gitignored.
  • A .secrets.baseline is 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.