A single committed access token can survive every future git rebase, mirror to every fork, and remain retrievable long after you delete the line that introduced it. This guide builds a layered defense that keeps secrets out of history in the first place, sits within the broader work of environment sync, secrets, and CI parity, and treats detection as a mechanical gate rather than a code-review honor system. You will wire gitleaks and trufflehog into a pre-commit hook so a staged secret never becomes a commit, add a CI job that scans full history and pull requests so a bypassed hook is still caught, and establish a triage workflow so every finding resolves to either a rotation or a reviewed allowlist entry — never a shrug.

The reason this matters is that Git is append-only by design. Unlike a leaked file on a server that you can delete, a secret in a commit is content-addressed into the repository's object store and referenced by every branch, tag, and clone that descends from it. Removing it means rewriting history and force-pushing, which breaks every teammate's checkout, and even then the secret lives on in anyone's local reflog and every CI cache until it expires. The only cheap fix is prevention: catch the secret while it is still a staged change on one machine, before it crosses the line into a shared, immutable object. This pairs directly with keeping resolved values out of the tree in the first place through managing local secrets without committing to Git.

Prerequisites

Before wiring the scanners in, confirm the toolchain versions below. Detection rules and command flags change between major releases, so pinning a known-good version keeps behavior identical on every workstation and on the runner — the same determinism principle the rest of this topic applies to dependencies and base images.

  • Git 2.30 or newer — earlier versions handle the pre-commit sample hook and core.hooksPath inconsistently across platforms.
  • gitleaks 8.18+ — the git, dir, and protect subcommands and the TOML config schema used below are stable from 8.18 onward.
  • trufflehog 3.63+ — the --only-verified flag and the filesystem / git source split assume the v3 CLI, not the archived v2 Python tool.
  • pre-commit 3.5+ (the framework) if you manage hooks declaratively; optional if you install a raw shell hook.
  • A shell with set -euo pipefail support (bash 4+ or any POSIX shell for the simpler blocks).

Verify each before continuing. A mismatched scanner version is itself a drift vector: a rule that fires locally but not in CI produces exactly the "green on my machine" failure this whole discipline exists to prevent.

#!/usr/bin/env bash
set -euo pipefail

git --version
gitleaks version
trufflehog --version
pre-commit --version

# Fail early if any tool is missing rather than discovering it mid-commit
for tool in git gitleaks trufflehog pre-commit; do
  command -v "$tool" >/dev/null 2>&1 || { echo "MISSING: $tool" >&2; exit 1; }
done
echo "All secret-scanning tools present"

The layered model this guide implements has four independent catch points, each strictly cheaper to act on than the next. Understanding where a leak can be stopped — and what it costs once it slips past each gate — frames every decision that follows.

Layered secret-catch points from edit to remote history Four defense stages left to right — working tree edit, pre-commit hook, CI pull-request scan, and full-history scan — each catching leaks the previous one missed. Where a Secret Can Be Caught Working tree staged diff cheapest fix Pre-commit hook blocks commit local, instant CI on the PR scans the diff catches bypass History scan full audit rotate + purge Cost of remediation rises sharply left to right — stop leaks as early as possible.
Four independent catch points; each secret that slips a gate becomes materially more expensive to remove.

Section 1 - Establish a clean baseline by scanning existing history

Before adding any preventive hook, you must know whether the repository is already contaminated. Installing a pre-commit gate on a repo that already has an AWS key three years deep in history gives false confidence: new commits are clean, but the old secret is still live and still cloneable. The first move is therefore a full-history scan that produces an inventory of what is already there.

Run gitleaks against the entire commit graph. The git subcommand walks every reachable commit and diff, applying the default rule set — over a hundred patterns for cloud provider keys, private key blocks, and high-entropy strings.

#!/usr/bin/env bash
set -euo pipefail

# Scan all history; write findings to a report and keep going on hits
gitleaks git . \
  --report-format json \
  --report-path gitleaks-history.json \
  --redact \
  --exit-code 0

FOUND=$(jq 'length' gitleaks-history.json)
echo "gitleaks found ${FOUND} candidate secret(s) in history"

The --redact flag replaces the matched secret with REDACTED in the report so you are not writing a fresh copy of every leaked credential to a JSON file on disk. --exit-code 0 forces a zero exit even when findings exist, because at the baseline stage you want the full inventory, not a failed command that stops at the first hit.

Now corroborate with trufflehog, which differs in a way that matters: it can verify a candidate by making a live, read-only API call to the issuing provider to check whether the credential is currently active. A verified finding is not a guess — it is a working key that must be rotated immediately.

#!/usr/bin/env bash
set -euo pipefail

# --only-verified filters to credentials confirmed live against their provider
trufflehog git file://. \
  --only-verified \
  --json > trufflehog-verified.json || true

VERIFIED=$(wc -l < trufflehog-verified.json | tr -d ' ')
echo "trufflehog confirmed ${VERIFIED} LIVE credential(s) in history"

Treat any verified finding as an active incident: rotate the credential at its source first, then worry about scrubbing history. A revoked key in an old commit is an audit note; a live key is an open door. The two tools are complementary rather than redundant — gitleaks casts a wide regex-and-entropy net for anything that looks like a secret, while trufflehog narrows to what is provably exploitable.

gitleaks versus trufflehog detection model Two columns comparing gitleaks pattern-and-entropy detection against trufflehog live credential verification across four properties. gitleaks vs trufflehog gitleaks regex + entropy rules wide net, fast TOML config + allowlist no network needed best as the commit gate trufflehog detector + live verify narrow, provable 700+ credential types calls provider API best for triage + audit
Run both: gitleaks as the fast preventive gate, trufflehog to confirm which findings are live.

Section 2 - Block secrets at commit time with a pre-commit hook

The preventive layer is a hook that runs the scanner against staged content only and refuses to create the commit if it finds anything. Scanning staged content — not the whole tree — keeps the hook fast enough that developers never feel the urge to disable it, which is the single most common way secret gates fail in practice.

The declarative approach uses the pre-commit framework so the hook version is pinned in the repo and installed identically on every clone. Add gitleaks to .pre-commit-config.yaml:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
        name: Detect hardcoded secrets
        description: Scan staged changes for credentials before commit

Then install and confirm the hook is active. pre-commit install writes a core.hooksPath shim so the framework's runner fires on every git commit.

#!/usr/bin/env bash
set -euo pipefail

pre-commit install
# Exercise the hook against the whole tree once to seed caches
pre-commit run gitleaks --all-files
echo "gitleaks pre-commit hook installed and passing"

If you prefer no framework dependency, a raw hook calling gitleaks protect does the same job. The protect subcommand is purpose-built for this: it scans the staged diff (--staged) rather than history, so it is the fast path for a commit-time gate.

#!/usr/bin/env bash
# .git/hooks/pre-commit  (chmod +x this file)
set -euo pipefail

# --staged scans only what is about to be committed; non-zero exit blocks it
if ! gitleaks protect --staged --redact --verbose; then
  echo "" >&2
  echo "COMMIT BLOCKED: gitleaks found a secret in your staged changes." >&2
  echo "Remove or allowlist it, then re-stage. Never use --no-verify to force it." >&2
  exit 1
fi

The numbered path to a working commit gate is:

  1. Choose the framework hook (portable, pinned) or the raw gitleaks protect hook (zero dependencies). Do not run both — they duplicate work and double the commit latency.
  2. Pin the exact scanner version in the config or in a comment next to the raw hook, so every teammate runs identical rules.
  3. Run pre-commit run --all-files (or gitleaks git .) once at install time to confirm the current tree is clean before you start relying on incremental scans.
  4. Commit the config so the gate travels with the repository rather than living only on your machine.

The drift-diagnostic here is a deliberate test leak. Stage a file containing an obvious fake key and confirm the hook rejects it — a gate you have never seen fire is a gate you cannot trust.

#!/usr/bin/env bash
set -euo pipefail

echo 'aws_secret = "AKIAIOSFODNN7EXAMPLE"' > /tmp/leak-test.txt
cp /tmp/leak-test.txt ./leak-test.txt
git add leak-test.txt

# This commit MUST fail; if it succeeds, the hook is not wired up
if git commit -m "test: should be blocked"; then
  echo "FAIL: hook did not block a known secret" >&2
  git reset --soft HEAD~1
  exit 1
else
  echo "PASS: hook blocked the staged secret as expected"
fi
git reset leak-test.txt && rm -f leak-test.txt

Section 3 - Scan pull requests and full history in CI

A local hook is necessary but not sufficient. A developer can bypass it with git commit --no-verify, a fresh clone might not have run pre-commit install, and a hook only ever saw the commits made on that one machine. The CI layer is the backstop: it scans in an environment nobody can skip, so a bypassed or missing local hook is still caught before the branch merges. This mirrors the same local-plus-CI enforcement pattern used for enforcing CI and local variable parity.

Two scan scopes belong in CI, and they answer different questions. A pull-request diff scan asks "does this change introduce a new secret?" and runs on every PR. A full-history scan asks "is there any secret anywhere in this repo's past?" and runs on a schedule or on the default branch, since it is slower and its answer changes rarely.

The workflow below runs both. gitleaks ships an official Action; pointing it at the checkout with full depth (fetch-depth: 0) lets it walk complete history rather than the single shallow commit CI checks out by default.

# .github/workflows/secret-scan.yml
name: secret-scan
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1"   # weekly full-history audit, Mondays 06:00 UTC

jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout with full history
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Run gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITLEAKS_CONFIG: .gitleaks.toml

  trufflehog:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout with full history
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Verify live credentials only
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

For teams on GitLab or self-hosted runners without the Action, invoke the CLI directly. Running the pipeline step in a container pinned to the same scanner version you use locally is how you guarantee the CI verdict matches the pre-commit verdict — the identical-image discipline from reproducing CI-only test failures locally with act.

# .gitlab-ci.yml (secret scanning stage)
secret-scan:
  stage: test
  image: zricethezav/gitleaks:v8.18.4
  script:
    - gitleaks git . --report-format sarif --report-path gitleaks.sarif --exit-code 1
  artifacts:
    when: always
    paths:
      - gitleaks.sarif

The critical configuration detail is the exit code. In CI you want the opposite of the baseline scan: --exit-code 1 means the job fails the moment a finding appears, which blocks the merge. Emitting SARIF as an artifact lets the platform surface each finding inline on the PR diff rather than burying it in a raw log, so the developer sees exactly which line tripped the scanner.

Give the scan job the narrowest permissions that still let it read the checkout and post results. On GitHub, that means contents: read for the checkout and, if you upload SARIF to the security tab, security-events: write — nothing more. A secret scanner that runs with a broad, write-capable token is itself an attack surface, and the trufflehog verification step in particular makes outbound network calls, so it should never hold credentials beyond what it needs to read code. Where the runner is self-hosted, ensure the scanner image is pulled by digest rather than a floating tag, so a compromised upstream tag cannot silently replace the tool that guards your secrets. This is the same supply-chain hygiene the rest of the parity work applies to base images.

The drift-diagnostic for the CI layer is to open a draft PR that adds a known test pattern and confirm the check goes red. If CI passes a deliberate leak, the backstop is not actually installed — most often because the checkout was shallow and the scanner never saw the offending commit.

Scan wall-clock time by scope Bar chart comparing seconds for a staged pre-commit scan, a pull-request diff scan, and a full-history scan on a sample repository. Scan Time by Scope (seconds) staged diff 0.4s PR diff 6s full history 48s Sample: ~40k-commit repo. Keep the fast scan on the hot path; schedule the slow one.
Scan cost scales with scope — run the sub-second staged scan on every commit and reserve the full-history walk for CI schedules.

Section 4 - Tune detection with a config and a reviewed allowlist

Every regex-and-entropy scanner produces false positives: a base64-encoded test fixture, an example key in documentation, a high-entropy hash that is not a secret at all. Left unmanaged, these erode trust until someone adds --no-verify to their muscle memory. The fix is a version-controlled .gitleaks.toml that extends the default rules and carries an explicit, reviewed allowlist — so every suppressed finding is a decision someone made in a diff, not silent noise.

Start from the bundled defaults and add only your own rules and exceptions on top. The extend.useDefault key pulls in the full built-in rule set so you are augmenting, not replacing, gitleaks' detection.

# .gitleaks.toml
title = "Repository secret-scanning config"

[extend]
useDefault = true

# A repo-specific rule the defaults do not cover
[[rules]]
id = "internal-service-token"
description = "Acme internal service token"
regex = '''acme_svc_[0-9a-zA-Z]{32}'''
keywords = ["acme_svc_"]

[allowlist]
description = "Reviewed non-secrets — every entry justified in review"
# Ignore known-safe example values by their exact fingerprint
paths = [
  '''docs/examples/.*''',
  '''.*_test\.go$''',
]
regexes = [
  '''AKIAIOSFODNN7EXAMPLE''',   # canonical AWS docs placeholder
]

Prefer path- and fingerprint-scoped allowlist entries over broad regex suppressions. A paths entry that ignores docs/examples/ is narrow and auditable; a loose regex that ignores anything containing token will one day swallow a real credential. When gitleaks reports a finding you have decided is safe, capture its fingerprint — the stable commit:file:rule:line identifier in the JSON report — and allowlist that exact fingerprint rather than the value, so the exception applies to one reviewed instance and nothing else.

#!/usr/bin/env bash
set -euo pipefail

# Extract fingerprints of current findings for a precise allowlist
gitleaks git . --report-format json --report-path /tmp/gl.json --exit-code 0
jq -r '.[] | .Fingerprint' /tmp/gl.json
echo "Add any reviewed-safe fingerprint above to the [allowlist] stopwords/commits"

The numbered tuning loop is:

  1. Run the scan and read every finding. Do not bulk-suppress.
  2. For each, decide: real secret (go to triage in Section 5), or genuine false positive.
  3. For a false positive, add the narrowest allowlist entry that covers it — a path glob or an exact fingerprint, never a broad value regex.
  4. Re-run to confirm the finding is gone and no real finding was suppressed as collateral.
  5. Commit the config change with a message that names why each exception is safe, so the review trail lives in history.

The drift-diagnostic is a periodic audit of the allowlist itself: a suppression that made sense two years ago may now be hiding a real leak in a file that has since changed purpose. Grep the config, re-justify each entry, and delete any you can no longer explain.

Section 5 - Triage and remediate a confirmed finding

When a scan surfaces something real, guesswork is the enemy. A deterministic triage path turns "there's a hit in the log" into a specific action every time: verify whether it is live, rotate if so, then decide whether history needs rewriting. The decision tree below is the exact routing to apply to any finding, and it keeps the expensive, disruptive step — history rewrite — reserved for the cases that actually warrant it.

Secret-finding triage decision path A decision starting from a scan finding, splitting on whether the credential is live, then on whether it reached a shared branch, into rotate, purge, or allowlist outcomes. Triaging a Finding Is it a real secret? verify with trufflehog No — false positive allowlist the fingerprint Yes — rotate now revoke at the provider Only local commit? amend, never pushed Shared history? purge + force-push
Verify first, rotate before you scrub, and reserve the history rewrite for secrets that reached a shared branch.

The order is non-negotiable: rotation comes before history rewriting. The instant a live credential touches any pushed branch, assume it is compromised — clones, forks, CI caches, and mirror bots may already hold it. Revoking and reissuing the credential at the provider closes the actual exposure; scrubbing it from history only stops the same leaked string from being rediscovered later. A pristine history around a still-valid key is theater.

Rotation is provider-specific but always follows the same shape: revoke the exposed credential, mint a replacement, and distribute the new value through your secret manager rather than another commit. For the mechanics of getting the fresh value to running services without downtime, see rotating secrets without restarting containers.

#!/usr/bin/env bash
set -euo pipefail

# Example shape — revoke, then confirm the OLD key no longer authenticates
OLD_KEY_ID="AKIA_EXAMPLE_OLD"
aws iam delete-access-key --access-key-id "$OLD_KEY_ID" || true

# Prove the revocation: this call MUST now fail with the old credentials
if AWS_ACCESS_KEY_ID="$OLD_KEY_ID" aws sts get-caller-identity 2>/dev/null; then
  echo "DANGER: old key still authenticates — revocation did not take" >&2
  exit 1
fi
echo "Old credential confirmed revoked; issue and distribute a replacement"

Only once the secret is dead do you remove it from history. If the offending commit was never pushed, a git commit --amend or a soft reset is enough and disrupts no one. If it reached a shared branch, you must rewrite history with a purpose-built tool — git filter-repo is the current recommendation — and force-push, which requires coordinating with everyone who has a clone.

#!/usr/bin/env bash
set -euo pipefail

# Purge a specific file from ALL history (destructive — rotate the key FIRST)
# Requires: pip install git-filter-repo
git filter-repo --path config/prod-secrets.env --invert-paths --force

echo "File removed from history. Coordinate a force-push:"
echo "  git push origin --force --all && git push origin --force --tags"
echo "Every collaborator must re-clone or hard-reset to the rewritten history."

Document the incident while the details are fresh. A short record — which credential, where it was found, when it was rotated, and which commits were rewritten — turns a one-off scramble into an auditable trail and lets you spot patterns, such as a particular service whose keys keep landing in commits because its setup docs tell developers to paste them into a file. That pattern is a signal to fix the upstream workflow, not just the symptom: route that service's credential through session injection so there is nothing to paste. Prevention at the source is always cheaper than repeated remediation downstream.

The drift-diagnostic that closes the loop is a re-scan after remediation: run the full-history gitleaks scan again and confirm the finding count drops to zero, and run trufflehog --only-verified to confirm no live credential remains reachable. A remediation you have not re-scanned is a remediation you have not verified.

Platform caveats

The scanners themselves are cross-platform Go binaries, but the surrounding Git and hook plumbing behaves differently across environments in ways that silently defeat the gate.

macOS (Docker Desktop): The default BSD grep/sed in helper scripts around the hooks parse differently from GNU tools; if you wrap the scanner in shell glue, install coreutils via Homebrew or call the scanner directly. gitleaks and trufflehog binaries themselves are unaffected — the risk is in scripts that post-process their output.

Apple Silicon (ARM64): Install the native arm64 scanner build, not an amd64 binary under Rosetta. An emulated scanner still detects correctly but runs several times slower, which pushes a full-history scan past the point where developers tolerate it on the commit path. Pin platform: linux/arm64 in any container that runs the scan locally.

WSL2: Keep the repository on the Linux filesystem (~/code, not /mnt/c). Git hooks installed via core.hooksPath fire unreliably when the repo lives on the 9P-mounted Windows drive, and file permissions on the hook script may not carry the execute bit, so the pre-commit gate silently never runs. Confirm with git config core.hooksPath and ls -l .git/hooks/pre-commit after install.

Treat these as install-time checks: verify the native binary architecture, confirm the hook is executable, and confirm its path resolves on the filesystem where the repo actually lives. A gate that does not fire is worse than no gate, because it manufactures false confidence.

Rollback and recovery

Each layer added above can be reverted independently without touching the others, which matters when a mis-tuned rule blocks legitimate work and you need to unblock the team while you fix the config.

To temporarily disable the pre-commit hook without uninstalling it, unset the hooks path or uninstall the framework shim:

#!/usr/bin/env bash
set -euo pipefail

# Reversible: uninstall the pre-commit framework hook
pre-commit uninstall

# Or, for a raw hook, disable by clearing the execute bit (keeps the file)
chmod -x .git/hooks/pre-commit 2>/dev/null || true
echo "Commit gate disabled locally. Re-enable with: pre-commit install"

Never reach for git commit --no-verify as the routine escape hatch — it bypasses every hook, not just the misbehaving one, and it becomes a habit that guts the whole gate. If a specific finding is a false positive, allowlist it narrowly (Section 4) rather than disabling the scanner. To roll back the CI layer, revert the workflow file in a normal PR; to roll back a config change that over-suppressed, git revert the .gitleaks.toml commit so the change and its reasoning both stay in history. The one action that cannot be rolled back is a history rewrite: once you have force-pushed a purged history, coordinate re-clones immediately, because any teammate who pushes from an un-reset checkout will reintroduce the removed commits.

Frequently Asked Questions

Do I still need to rotate a secret if I catch it in the pre-commit hook before it is pushed?

If the hook blocked the commit and the secret never entered any commit object, no rotation is needed — the value existed only in your working tree and staging area, which are local and disposable. Remove the value, re-stage, and continue. Rotation becomes mandatory the moment the secret reaches a commit that was pushed to any shared remote, because from that point you must assume clones, forks, and CI caches may hold a copy. When in doubt about whether a value was ever pushed, treat it as compromised and rotate; the cost of an unnecessary rotation is minutes, and the cost of a missed live leak is unbounded.

Why run both gitleaks and trufflehog instead of picking one?

They answer different questions and are strongest at different jobs. gitleaks is a fast, offline, regex-and-entropy scanner with a tunable TOML config — ideal as the commit-time gate and the PR diff check because it needs no network and finishes in milliseconds on a staged diff. trufflehog can verify a candidate against the issuing provider's API, so it tells you which findings are live credentials versus revoked or fake ones — invaluable during triage and periodic audits where you must prioritize real exposure. Use gitleaks to keep secrets out and trufflehog to confirm what is actually dangerous when something slips through.

How do I stop a legitimate example key in my docs from failing every scan?

Add a narrow, reviewed entry to your .gitleaks.toml allowlist — scoped to the exact file path (for example a docs/examples/ glob) or to the finding's stable fingerprint, never to a broad value regex. A path- or fingerprint-scoped exception suppresses one known-safe instance and nothing else, and because it lives in a committed config it is visible in review and auditable later. Avoid loose regex suppressions like anything containing the word "token": they inevitably swallow a real credential down the line, which is the exact failure the scanner exists to prevent.

My CI secret scan passes but I know there is a secret in an old commit — why?

Almost always because CI checked out a shallow copy. The default checkout fetches a single commit, so the scanner never walks the history where the old secret lives. Set fetch-depth: 0 on the checkout step so the full commit graph is available, then re-run. The other common cause is a scan scoped to the diff rather than history: a PR diff scan by design only inspects the change under review, so schedule a separate full-history job to audit the past. Confirm by running gitleaks git . against a full local clone and comparing the finding count.