How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

This guide is for engineers who want to stop API keys, tokens and private keys from reaching a git repository, and who need a plan for the day one gets through anyway. By the end you will have Gitleaks and TruffleHog running as pre-commit hooks, GitHub push protection enabled, a CI job that scans every pull request with pinned actions, a rotation runbook, and the exact commands to purge a secret from history. Versions checked on 20 October 2026: gitleaks v8.30.1, TruffleHog v3.99.3, pre-commit v4.7.0 and git-filter-repo v2.47.0. You need git, Python 3 for pre-commit, and admin rights on the GitHub repository for the push protection step. If you want the why before the how, my sibling post What Is DevSecOps in 2026? covers the data.

The short version of why this matters: GitGuardian counted 28.65 million new hardcoded secrets on public GitHub in 2025, up 34%, and more than 64% of secrets it confirmed valid in 2022 were still valid when retested in January 2026. Detection is not the hard part. Rotation is.

Three layers of prevention, one response path 1. Laptop: pre-commit gitleaks git --staged trufflehog --results=verified catches it before a commit exists 2. GitHub push protection blocks known token formats GH013 on git push bypasses are logged and alerted 3. CI on every PR gitleaks binary, checksum checked TruffleHog action, pinned SHA fetch-depth: 0 leak found A. Rotate first new key, deploy, disable old the leak is now harmless B. Check for use CloudTrail, provider audit logs from leak time to revocation C. Clean history (optional) git filter-repo, force push GitHub Support for PR refs Order matters: rewriting history does not un-leak a key that was already cloned, cached or scraped. Rotation fixes the risk. History cleanup only reduces future rediscovery, and is often skippable.
The setup this guide builds: three prevention layers (pre-commit, push protection, CI) and the response order GitHub's own documentation recommends when one gets through: rotate first, rewrite history last.

Step 1: Install gitleaks, TruffleHog and pre-commit

On macOS or Linux with Homebrew:

brew install gitleaks trufflehog pre-commit git-filter-repo

gitleaks version        # expect: 8.30.1
trufflehog --version    # expect: trufflehog 3.99.3
pre-commit --version    # expect: pre-commit 4.7.0
git filter-repo --version

Homebrew always gives you the latest formula. If you need exact versions (you do, in CI), download release binaries and verify checksums, which Step 6 shows. pipx works for the Python tools: pipx install pre-commit==4.7.0 and pipx install git-filter-repo==2.47.0.

Why two scanners? They are good at different things. Gitleaks is regex and entropy based, fast, and works offline, so it is ideal for a hook that runs on every commit. TruffleHog classifies over 800 secret types and can verify a candidate by calling the provider's API, which turns "this looks like a Stripe key" into "this is a live Stripe key". That verification is what makes it useful in CI, where you want few alerts and every one of them real.

Step 2: Scan your existing history and create a baseline

Before you add a hook, find out what is already in the repository. Otherwise the first CI run will fail on a key committed in 2021 and someone will disable the job.

cd your-repo

# Gitleaks: scan every commit reachable from all refs, redact secrets in output
gitleaks git --redact -v --log-opts="--all" --report-path gitleaks-report.json .

# TruffleHog: scan full history, show only credentials it could verify as live
trufflehog git file://. --results=verified,unknown

A Gitleaks finding looks like this (redacted, trimmed):

Finding:     aws_secret_access_key = REDACTED
Secret:      REDACTED
RuleID:      aws-access-token
File:        deploy/old-config.env
Line:        4
Commit:      9f2c1e7...
Fingerprint: 9f2c1e7...:deploy/old-config.env:aws-access-token:4

For each real finding, go to Step 7 and rotate the credential. Then decide whether the historical findings should block future scans. Gitleaks supports a baseline: any report can be passed back in, and only new findings are reported.

# Keep the report out of git, it may contain partial secrets
echo "gitleaks-report.json" >> .gitignore

gitleaks git --baseline-path gitleaks-report.json --report-path findings.json .

TruffleHog exit code 183 means "no errors, but results were found" and is only returned with --fail, according to its README. Exit code 1 from Gitleaks means leaks were found (configurable with --exit-code).

Step 3: Add gitleaks and TruffleHog as pre-commit hooks

Create .pre-commit-config.yaml at the repository root:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks          # runs: gitleaks git --pre-commit --redact --staged --verbose
  - repo: https://github.com/trufflesecurity/trufflehog
    rev: v3.99.3
    hooks:
      - id: trufflehog        # runs: trufflehog git file://. --since-commit HEAD --results=verified --fail

Install the git hook and run it once against every file:

pre-commit install            # writes .git/hooks/pre-commit
pre-commit run --all-files    # first run builds both Go hooks, so it is slow once

# Optional: pin hook revisions to immutable commit SHAs instead of tags
pre-commit autoupdate --freeze

The hook entries above are the ones each project ships in its .pre-commit-hooks.yaml. The gitleaks id builds from source with Go; use gitleaks-system if you already have the binary on your PATH, or gitleaks-docker to run the official image.

Test it with a fake token that matches a real format (this one is random and not a credential):

echo 'token = "ghp_Xk2d9QpL7vRz4mN8sT1yB6cW3hF0jE5aU2gV"' > leak-test.py
git add leak-test.py
git commit -m "test"

Expected result: the commit is refused.

Detect hardcoded secrets.................................................Failed
- hook id: gitleaks
- exit code: 1
...
RuleID:      github-pat
File:        leak-test.py

Clean up with git rm --cached leak-test.py && rm leak-test.py. The TruffleHog hook will not fire on this fake token, because it only reports verified results and the token is not live. That is by design, and it is why Gitleaks is the one that does the blocking locally.

A pre-commit hook is a seatbelt, not a lock. Anyone can skip it with git commit --no-verify or SKIP=gitleaks git commit, and a fresh clone has no hooks until someone runs pre-commit install. That is why Steps 5 and 6 exist.

Step 4: Handle false positives without disabling the scanner

You have three tools, from narrowest to broadest.

Inline comment for a known test value on one line:

STRIPE_TEST_KEY = "sk_test_placeholder_not_real"  # gitleaks:allow

.gitleaksignore for a specific finding by fingerprint (copy it from the report). Fingerprints are commit:file:rule:line for history scans and file:rule:line for staged scans:

9f2c1e7d4b1a0c8e6f3d2b1a09f8e7d6c5b4a3f2:tests/fixtures/jwt.txt:jwt:12

.gitleaks.toml to extend the default rules with path allowlists. Note the v8.25.0 syntax change to [[allowlists]]:

# .gitleaks.toml (picked up automatically from the repo root)
title = "repo gitleaks config"

[extend]
useDefault = true                 # keep all built-in rules
# disabledRules = ["generic-api-key"]   # only if it is drowning you

[[allowlists]]
description = "test fixtures and lockfiles"
paths = [
  '''tests/fixtures/.*''',
  '''(.*?)package-lock\.json$''',
]

Be stingy with path allowlists. A tests/ exclusion is exactly where someone pastes a real staging key "just for this test". For agent tooling, do the opposite and make sure directories like .cursor/, .claude/ and MCP config files are scanned: GitGuardian found 24,008 unique secrets in MCP-related configuration files in 2025.

Step 5: Turn on GitHub push protection

Push protection is GitHub's server-side check: it rejects a push containing a recognised secret before it lands. Per GitHub's docs, user-level push protection is on by default for pushes to public repositories, while repository-level push protection is off by default and requires GitHub Secret Protection on private repositories (priced at $19 per active committer per month). It covers command-line pushes, web UI commits, file uploads, REST API requests and GitHub MCP server interactions on public repositories.

Enable it with the GitHub CLI:

OWNER=your-org REPO=your-repo
gh api -X PATCH "repos/$OWNER/$REPO" \
  -f 'security_and_analysis[secret_scanning][status]=enabled' \
  -f 'security_and_analysis[secret_scanning_push_protection][status]=enabled'

# Confirm
gh api "repos/$OWNER/$REPO" --jq '.security_and_analysis'

Or in the UI: Settings, then Advanced Security (Code security on older plans), then enable Secret Protection and Push protection. For an organisation, apply it through a security configuration so new repositories inherit it.

A blocked push looks like remote: error: GH013: Repository rule violations found followed by "Push cannot contain secrets" and a link to the finding. Anyone with write access can bypass with a reason. "Used in tests" and "false positive" create closed alerts; "I'll fix it later" creates an open one. Every bypass lands in the audit log and emails repository admins, so review those weekly. If you want only security staff to approve bypasses, enable delegated bypass.

Step 6: Scan every pull request in CI

Hooks can be skipped, and push protection only knows provider token formats. CI is the layer that everyone passes through. This workflow runs both scanners with every third-party action pinned to a full commit SHA, which matters: Datadog's State of DevSecOps 2026 found only 4% of organisations pin all their marketplace actions to a hash. For the wider topic see GitHub Actions security in 2026.

# .github/workflows/secret-scan.yml
name: secret-scan
on:
  pull_request:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  gitleaks:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          fetch-depth: 0
      - name: Install gitleaks 8.30.1 (checksum verified)
        run: |
          V=8.30.1
          curl -sSLO "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_x64.tar.gz"
          curl -sSLO "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_checksums.txt"
          grep "gitleaks_${V}_linux_x64.tar.gz" "gitleaks_${V}_checksums.txt" | sha256sum -c -
          tar -xzf "gitleaks_${V}_linux_x64.tar.gz" gitleaks
      - name: Scan commits in this PR or push
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
          else
            RANGE="${{ github.event.before }}..${{ github.sha }}"
          fi
          ./gitleaks git --redact -v --log-opts="$RANGE" .

  trufflehog:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          fetch-depth: 0
      - uses: trufflesecurity/trufflehog@e63326d6545de8f425d93ce24078c6e68d83f70e  # v3.99.3
        with:
          version: 3.99.3                     # image tag; the default is "latest"
          extra_args: --results=verified,unknown

Two details that are easy to miss. The official gitleaks-action needs a free GITLEAKS_LICENSE for repositories owned by an organisation, which is why this workflow runs the binary directly. And the TruffleHog action's version input defaults to latest: pinning the action SHA alone still pulls whatever image is newest. I checked that ghcr.io/trufflesecurity/trufflehog:3.99.3 exists (no v prefix; v3.99.3 returns 404).

Make both jobs required status checks in your branch protection or ruleset, otherwise a red check is only a suggestion.

Step 7: A secret leaked. Rotate first.

GitHub's own guidance is blunt: revoke or rotate the credential before anything else, because once it cannot be used, you may not need to rewrite history at all. Assume it was scraped within minutes if the repository is public, and remember that the npm worms of the past year harvested credentials straight from developer machines and CI runners, not only from repositories.

For an AWS IAM user access key, the sequence is create, deploy, deactivate, investigate, delete:

USER=ci-deployer
OLD=AKIA...LEAKED   # the leaked access key ID

# 1. Create the replacement (an IAM user can have at most two keys)
aws iam create-access-key --user-name "$USER"

# 2. Deploy the new key to your secret manager and consumers, then:
aws iam update-access-key --user-name "$USER" --access-key-id "$OLD" --status Inactive

# 3. See what the leaked key did (CloudTrail event history covers 90 days)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=AccessKeyId,AttributeValue="$OLD" \
  --max-results 50 --query 'Events[].[EventTime,EventName,Username]' --output table

# 4. Once nothing breaks, delete it
aws iam delete-access-key --user-name "$USER" --access-key-id "$OLD"

The same pattern applies everywhere: GitHub tokens are revoked from Developer settings, Stripe and OpenAI keys are rolled from their dashboards, database passwords are changed and connection strings redeployed. Better still, remove the long-lived key entirely: workload identity federation lets CI authenticate to AWS, GCP and Azure without a stored secret to leak.

Step 8: Remove the secret from git history with git filter-repo

Only after rotation. Rewriting history changes every commit hash after the leak, breaks open pull requests, invalidates signatures and forces every collaborator to re-clone. Use git-filter-repo 2.47 or later for the --sensitive-data-removal flag. GitHub's current guide uses git-filter-repo rather than BFG.

# Work in a fresh mirror clone, not your working copy
git clone --mirror git@github.com:your-org/your-repo.git
cd your-repo.git

# Option A: delete a file from every commit (use the full path in the repo)
git filter-repo --sensitive-data-removal --invert-paths --path deploy/old-config.env

# Option B: replace the secret string wherever it appears
# Each line is a literal; "==>" sets the replacement (default is ***REMOVED***)
printf '%s\n' 'wJalrXUtnFEMI-the-leaked-value==>REDACTED' > ../replacements.txt
git filter-repo --sensitive-data-removal --replace-text ../replacements.txt

# How many pull request refs still hold the old commits?
grep -c '^refs/pull/.*/head$' filter-repo/changed-refs

# Force push every rewritten ref
git push --force --mirror origin

Pushes to refs/pull/* will fail; GitHub marks them read-only. If other refs fail, branch protection is blocking the force push, so lift it temporarily. Then open a GitHub Support ticket with the repository name, the number of affected pull requests and the "First Changed Commit(s)" from the filter-repo output, so they can dereference old PRs, garbage-collect and purge cached views. Forks are out of your hands. Finally, every collaborator must re-clone: a single git pull and git push from an old clone puts the secret straight back.

Troubleshooting

Symptom Likely cause Fix
Executable gitleaks not found from pre-commit Using the gitleaks-system hook id without the binary on PATH Switch to id: gitleaks (builds with Go) or gitleaks-docker, or install the binary
First pre-commit run takes minutes Go hooks compile on first use One-time cost, cached afterwards; pre-build in CI images if it hurts
Hook passes but CI flags the same secret Developer used --no-verify, SKIP=, or never ran pre-commit install Expected; CI is the enforcing layer. Make the job a required check
CI scan finds nothing in an obviously leaky PR Shallow checkout (fetch-depth: 1) so the commit range is missing Set fetch-depth: 0 on actions/checkout
TruffleHog action fails: BASE and HEAD commits are the same base and head inputs resolve to one commit, often on a default-branch push Remove the explicit base/head inputs and let the action derive them from the event
TruffleHog hook never fires on test tokens Hook uses --results=verified; fake tokens cannot verify Expected. Rely on Gitleaks for format-based blocking
Gitleaks flags fixtures, lockfiles or UUIDs generic-api-key and entropy rules are broad # gitleaks:allow, a .gitleaksignore fingerprint, or a path in [[allowlists]]
Old [allowlist] config silently ignored or warned Syntax changed to [[allowlists]] in v8.25.0 Rename the table and re-test with gitleaks dir -v .
git push rejected with GH013 Push protection matched a provider token Remove the secret and amend the commit; bypass only with an honest reason
filter-repo aborts: "does not look like a fresh clone" Running in a working copy with local changes or reflogs Use git clone --mirror (or --no-local for local paths); --force only if you understand why
unrecognized arguments: --sensitive-data-removal git-filter-repo older than 2.47 Upgrade to 2.47.0
Secret reappears after the rewrite A collaborator pushed from a pre-rewrite clone Have everyone re-clone; re-run the rewrite; rotation already made it harmless

FAQ

Should I use gitleaks or TruffleHog?

Both, in different places. Gitleaks is fast and offline, so it suits a pre-commit hook that must finish in a second. TruffleHog verifies candidates against provider APIs, so its CI findings are mostly real, live credentials worth paging someone for.

Does GitHub push protection replace pre-commit hooks?

No. Push protection only blocks formats GitHub recognises, and repository-level protection needs GitHub Secret Protection for private repositories. A pre-commit hook catches the secret before a commit exists at all, which also keeps it out of your local history and any other remote.

Do I need to rewrite git history after leaking a secret?

Often not. If the credential is rotated and no longer works, the string in history is harmless, and GitHub itself says rotation may make a rewrite unnecessary. Rewrite when the data cannot be rotated, such as personal data or a customer's private key.

How do I find secrets in a repository I just inherited?

Run gitleaks git --redact -v --log-opts="--all" . for a fast pattern scan, then trufflehog git file://. --results=verified to see which findings are still live. Rotate the verified ones first, then save a Gitleaks baseline so CI only fails on new leaks.

In production, the first thing I check on any repository is not whether a scanner is installed but whether the CI job is a required check and whether push protection bypasses are being reviewed. The second is whether there is a written rotation runbook for each credential type, because the GitGuardian validity numbers say rotation is where everyone stalls. Reference docs: Gitleaks README, TruffleHog README, pre-commit, GitHub push protection and removing sensitive data from a repository. For the bigger picture of how leaked credentials spread, see secrets sprawl in 2026.

Related: The Worms Learned to Use Your AI Agent: A Year of npm Supply-Chain Attacks, Who Is This Agent? Non-Human Identity in 2026, From the RFCs to the Breaches and What Is DevSecOps in 2026? 87% Run Exploitable Code, 4% Pin Actions.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related