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.

Where the pre-commit hook intercepts a staged secret A left-to-right flow from git add to the pre-commit hook running gitleaks, which either aborts the commit on a finding or lets the commit object be written. The Hook Is the Only Veto Point git add stages the diff pre-commit hook gitleaks --staged Finding: abort exit 1, no commit Clean: proceed object written
The staged diff is the last moment a secret can be stopped before it becomes shared history.

Resolution

  1. 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 version
  2. Add 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.yaml at 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: false
  3. Install the git hook. This writes .git/hooks/pre-commit to 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"
  4. Tune .gitleaks.toml so 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''',
    ]
  5. Verify the hook blocks a real secret. Re-stage the demo leak and attempt a normal commit — no --no-verify this 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(+)
What to do when the hook blocks a commit A decision from whether a blocked finding is a real credential, leading to rotate and remove for a true positive or add an allowlist entry for a false positive. The Hook Blocked You — Now What? Is it a real secret? not a fixture Yes — true positive unstage, rotate the key, move it to a vault No — false positive add a path or regex to .gitleaks allowlist
Never blanket-disable the hook — either rotate the credential or narrow the allowlist.

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.

Scan scope and duration by enforcement layer Bar chart comparing how much Git content each scan mode reads: staged diff is smallest and fastest, full working tree larger, full history largest. Scan Duration by Scope (ms) staged diff 40ms working tree 320ms full history 2100ms
The pre-commit hook scans only the staged diff, so it stays fast enough to run on every commit.

Platform caveats

macOS (Docker Desktop): brew install gitleaks installs an ARM64 or Intel binary matching the host, so the uname -m detection in step 1 is unnecessary if you use Homebrew. If you script the tarball install instead, note that macOS reports arm64 (not aarch64); the sed in step 1 already normalises aarch64 but 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 using git.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 arm64 release assets, so the tarball URL resolves correctly with the uname -m mapping 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.