Blocking Committed Secrets with a gitleaks Pre-Commit Hook
An AWS_SECRET_ACCESS_KEY pasted into a config file sails through git commit and lands in shared history before anyone notices. This page installs a gitleaks pre-commit hook that scans the staged diff on every commit and exits non-zero the moment it finds an API key, access token, or private key — sitting under scan for and prevent leaked secrets and the wider environment sync and CI parity baseline.
The default git commit runs no content inspection. A hook is the only place you can veto a commit before the object enters the local database, which is exactly why a scanner belongs there rather than in a nightly job. The steps below install the gitleaks binary, wire it into the client-side pre-commit hook through the pre-commit framework, tune a .gitleaks.toml so real credentials block while test fixtures pass, and add a CI backstop for the machines where the hook never ran.
Diagnostic
First confirm the problem: a staged secret currently commits without complaint. Stage a fake AWS key and watch it go through:
#!/usr/bin/env bash
set -euo pipefail
printf 'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n' > leak-demo.env
git add leak-demo.env
git commit -m "add config" --no-verify
git log -1 --stat
Expected BAD output — the credential is now an immutable object in history:
[main 9f2 ac1] add config
1 file changed, 1 insertion(+)
create mode 100644 leak-demo.env
Nothing blocked it. Now run gitleaks by hand over the same working tree to prove the pattern is detectable — the scanner sees what the commit path ignored:
#!/usr/bin/env bash
set -euo pipefail
gitleaks detect --source . --no-banner --verbose || echo "gitleaks exit: $?"
Finding: AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Secret: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
RuleID: aws-secret-key
File: leak-demo.env
gitleaks exit: 1
The tool detects the key and returns exit code 1, but nothing invokes it during the commit. That gap between detectable and enforced is the entire problem, and closing it is a one-time wiring job.
It is worth understanding what gitleaks matched and why the commit path did not. gitleaks ships roughly a hundred built-in rules, each a named regular expression with an entropy threshold — aws-secret-key, github-pat, stripe-access-token, private-key, and so on. A rule fires when a line both matches the pattern and the captured secret clears a Shannon-entropy floor, which is how the scanner separates a real 40-character token from a lowercase English word of the same length. Git, by contrast, treats file contents as opaque blobs; it content-addresses them into the object database without reading a single byte for meaning. That design is deliberate and correct for a version-control system, but it means secret detection can only ever be a bolt-on. The hook is where you bolt it on.
Root cause
Git's commit pipeline has exactly one client-side veto point that fires before the commit object is written: the pre-commit hook, an executable at .git/hooks/pre-commit. Git ships it disabled (the sample file ends in .sample), so out of the box every staged byte is trusted. A secret only becomes "committed" once it is content-addressed into .git/objects; after that, removing it means rewriting history and rotating the credential, because anyone who pulled already has the plaintext. Enforcement therefore has to happen at the hook, not after. gitleaks protect --staged exists for precisely this window — it scans the staged diff (not the whole tree, not the whole history) and returns non-zero on a hit, which Git treats as "abort the commit."
The choice between the raw .git/hooks/pre-commit file and the pre-commit framework matters here. A hand-written hook lives inside .git, which is never committed, so it cannot be shared or version-controlled — every teammate has to recreate it, and there is no record of which version they run. The pre-commit framework moves the definition of the hook into a tracked .pre-commit-config.yaml and pins the gitleaks version with a rev: tag, so pre-commit install reproduces an identical hook on every clone. That reproducibility is the same principle applied elsewhere in the environment sync baseline: the enforcement rules travel with the repository, not with an individual's laptop. Pinning also protects you from a surprise: if you tracked HEAD instead of a tag, a new gitleaks release could add or change a rule overnight and start blocking commits that passed yesterday, with no diff to explain why.
Resolution
Install the gitleaks binary. Pin a version so every machine runs identical rules. On Linux, pull the release tarball; on macOS use Homebrew.
#!/usr/bin/env bash set -euo pipefail GITLEAKS_VERSION="8.18.4" ARCH="$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')" OS="$(uname -s | tr '[:upper:]' '[:lower:]')" URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${OS}_${ARCH}.tar.gz" curl -sSfL "$URL" | tar -xz -C /tmp gitleaks sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks gitleaks versionAdd the pre-commit framework config. Rather than hand-editing
.git/hooks, let the pre-commit framework manage the hook so a fresh clone can install it with one command. Create.pre-commit-config.yamlat the repo root:repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.18.4 hooks: - id: gitleaks name: gitleaks (block committed secrets) entry: gitleaks protect --staged --redact --no-banner language: system pass_filenames: falseInstall the git hook. This writes
.git/hooks/pre-committo call the framework, which in turn runs gitleaks against the staged diff:#!/usr/bin/env bash set -euo pipefail pip install --user pre-commit pre-commit install test -x .git/hooks/pre-commit && echo "hook installed"Tune
.gitleaks.tomlso fixtures pass and real keys fail. The default ruleset is deliberately aggressive, and on a real codebase it will flag some values that are not live credentials — the AWS documentation key, a placeholder in an integration test, an example JWT in the README. Rather than lower the entropy threshold globally (which would let real secrets through), extend the default ruleset and add a narrow allowlist scoped to the paths and literal strings you have verified are safe:title = "repo gitleaks config" [extend] useDefault = true [allowlist] description = "Ignore documented example values and test fixtures" paths = [ '''(.*)?/testdata/.*''', '''(.*)?/fixtures/.*''', ] regexes = [ '''EXAMPLEKEY''', '''AKIAIOSFODNN7EXAMPLE''', ]Verify the hook blocks a real secret. Re-stage the demo leak and attempt a normal commit — no
--no-verifythis time:#!/usr/bin/env bash set -euo pipefail printf 'GITHUB_TOKEN=ghp_016C7e1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6\n' > real-leak.env git add real-leak.env git commit -m "should be blocked" && echo "LEAK GOT THROUGH" || echo "commit correctly aborted"
Expected output
With the hook in place, the commit aborts and the credential never enters the object store:
gitleaks (block committed secrets)......................................Failed
- hook id: gitleaks
- exit code: 1
Finding: GITHUB_TOKEN=REDACTED
RuleID: github-pat
File: real-leak.env
commit correctly aborted
gitleaks protect --staged scanned only the staged hunk, matched the github-pat rule, printed the redacted finding (because of --redact), and returned 1. pre-commit surfaced that as a Failed hook and Git refused to create the commit. The --redact flag matters more than it looks: without it, gitleaks prints the full secret in the terminal, which then lands in your shell history and any CI log that captured stdout — re-leaking the very value you just blocked. Always keep it on. Note too that the finding names the exact file and rule, so the fix is unambiguous: you know which credential type tripped and where it lives, which is the difference between a five-second rotation and a scavenger hunt.
The fixture and allowlisted example values, meanwhile, commit without noise:
gitleaks (block committed secrets)......................................Passed
[main 4d1 e0a] add test fixture
1 file changed, 3 insertions(+)
Prevention
A client-side hook protects only the machines that installed it. A new contributor who skips pre-commit install, or anyone who runs git commit --no-verify, bypasses it entirely. Add a server-side backstop so the same rules run in CI, and a full-history sweep so an old leak surfaces even if it predates the hook. This mirrors the layered approach in keep secrets out of Git for good, where detection and prevention are separate lines of defence.
The CI job scans the full push range and fails the build on any finding:
name: secret-scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: run gitleaks
run: |
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks detect --source . --redact --no-banner --exit-code 1
Because fetch-depth: 0 fetches the whole history, gitleaks detect walks every commit — catching a secret that a --no-verify bypass slipped past the local hook. The default shallow checkout (fetch-depth: 1) fetches only the tip commit, so a secret buried three commits back would go unnoticed; the full fetch is what makes the CI job a real backstop rather than a duplicate of the hook. Run the same full-history scan locally before enabling the CI gate so you fix existing leaks first rather than blocking every future push on a pre-existing finding.
There is a subtle ordering concern when you turn the CI gate on for an existing repository. If history already contains a leaked key, the very first CI run fails — correctly — and will keep failing on every push until you remediate. Rotate the exposed credential, purge it from history, and force-push the cleaned refs before you merge the workflow file, or you will hand the team a red pipeline they cannot make green by any commit. For repositories with a long history where a full scan is slow, scope gitleaks detect to a commit range with --log-opts="<base>..<head>" on pull requests so each run only inspects the pushed range, and reserve the full-history sweep for a scheduled nightly job. The bar chart below shows why the pre-commit hook stays cheap while the history scan is the expensive tail: the hook reads only the staged hunk, so its cost is bounded by the size of a single change, not the age of the repository.
Platform caveats
macOS (Docker Desktop):
brew install gitleaksinstalls an ARM64 or Intel binary matching the host, so theuname -mdetection in step 1 is unnecessary if you use Homebrew. If you script the tarball install instead, note that macOS reportsarm64(notaarch64); thesedin step 1 already normalisesaarch64but Homebrew-managed hosts skip that path entirely.
WSL2: The hook runs inside the Linux distribution, so install the Linux binary there, not the Windows
.exe. Commits made from a Windows-side Git client (or an IDE usinggit.exe) will not fire the WSL hook at all — standardise on committing from inside WSL2, or install the hook in both environments.
Apple Silicon (ARM64): gitleaks ships native
arm64release assets, so the tarball URL resolves correctly with theuname -mmapping above. Do not run the Intel binary under Rosetta; the native build scans measurably faster on large diffs.
Rollback
To remove the hook without deleting your config, uninstall the pre-commit-managed hook and clear any core.hooksPath override that a prior setup may have left behind:
#!/usr/bin/env bash
set -euo pipefail
pre-commit uninstall
git config --unset-all core.hooksPath || true
gitleaks version
This leaves .pre-commit-config.yaml and .gitleaks.toml in the tree, so a later pre-commit install restores the exact same enforcement — the rollback is reversible and version-controlled. If you only need to skip the hook for one legitimate commit (a generated fixture the allowlist does not yet cover), prefer a single git commit --no-verify over uninstalling; that keeps the hook active for every other commit and does not touch your Git configuration. Never leave the hook uninstalled as a way to silence a finding — an unremediated secret does not stop being exposed just because the scanner stopped looking.
Frequently Asked Questions
Does git commit --no-verify skip the gitleaks hook?
Yes. --no-verify tells Git to skip all client-side pre-commit and commit-msg hooks, so gitleaks never runs for that commit. This is why the CI gitleaks detect job with fetch-depth: 0 is not optional — it is the layer that catches anything a local bypass let through. Treat --no-verify as an audited exception, not a routine escape hatch.
What is the difference between gitleaks detect and gitleaks protect?
gitleaks detect scans committed history (the full log by default) and is what you run in CI. gitleaks protect --staged scans only the uncommitted staged diff and is what belongs in the pre-commit hook, because at commit time the content is not yet a commit object. Using detect in a pre-commit hook would rescan all of history on every commit and be needlessly slow.
The hook flags a test fixture that is not a real secret. How do I stop that?
Add a targeted entry to .gitleaks.toml rather than disabling the rule globally. Put the fixture directory under [allowlist] paths (for example '''(.*)?/testdata/.*''') or add the literal placeholder to [allowlist] regexes. Keep the allowlist as narrow as possible so a real credential in a non-fixture path still blocks.
A secret already reached the remote before I installed the hook. Is the hook enough?
No. Once a secret is pushed, anyone who cloned or fetched has the plaintext, so the hook cannot undo the exposure. You must rotate the credential immediately and then purge it from history. The hook and CI scan prevent the next leak; remediation of an existing one is covered in the workflow to remove tracked secrets and rewrite history.