Blocking secrets with a pre-push scan Jump to heading

An API key, cloud access token, or private key that reaches the remote is not a bug you fix with a follow-up commit β€” it is an incident. The moment the blob lands on origin, anyone with clone access can extract it from history, and the only safe response is to rotate the credential. The cheapest place to stop that is the developer’s own machine, before any network traffic. A pre-push hook that scans the outbound commit range with a dedicated secret scanner catches the leak while it is still local and free to undo. This page is a focused recipe within Pre-Push Validation Rules, which covers the full architecture of local gating before remote transmission.

When to use this approach Jump to heading

Apply this recipe when:

  • Your repository has ever leaked a credential, or handles secrets that would be expensive to rotate (long-lived cloud keys, signing material, database passwords).
  • You want fast local feedback but understand the hook is advisory β€” the enforceable gate lives in CI, where --no-verify cannot reach.
  • You already run Local Hook Configuration with Husky for other checks and want to add a secret gate that evaluates the full outbound range, not just staged files.
  • Your supply-chain posture already includes Commit Signing & Git Supply-Chain Security; secret scanning is the leak-prevention half of that same story.
  • Contributors occasionally paste .env fragments, cloud CLI output, or config dumps into commits without noticing embedded credentials.

This recipe is not a replacement for a secrets manager. A scanner catches accidents; it does not make it safe to store secrets in the tree. If your workflow depends on committed credentials, fix that first. The scanner is a backstop, and β€” critically β€” it cannot un-leak anything that has already been pushed.

The diagram below shows where the scan sits and why the local hook and the CI job are two layers of the same defense, not duplicates.

Secret scan in the pre-push hook and its authoritative mirror in CIDiagram showing local commits flowing into a pre-push hook that scans the outbound range. A hit exits non-zero and blocks the push locally. A clean pass reaches origin, where the CI pipeline re-runs the same scan as the authoritative gate that cannot be bypassed.Local commitsgit pushpre-push hookgitleaks scansthe push range onlyexit 1 β€” leakblocked locallyhitcleanoriginremote updatedCI scansame rules,authoritative

Step-by-step recipe Jump to heading

Step 1 β€” Install a secret scanner and pin its version Jump to heading

Use gitleaks for the hook: a single static binary, fast, no network dependency, with a TOML allowlist that version-controls cleanly. Pin the version so every developer and CI runner scans with identical rules β€” a floating version silently changes what the gate catches.

# macOS
brew install gitleaks
# Linux β€” install a pinned release binary
GITLEAKS_VERSION=8.18.4
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
  | tar -xz -C /usr/local/bin gitleaks

Verify the binary is on PATH and reports the pinned version:

# Should print: 8.18.4
gitleaks version

Step 2 β€” Read the push refs from stdin and build the scan range Jump to heading

Git passes one line per ref to the hook’s stdin β€” four space-delimited fields: local ref, local SHA, remote ref, remote SHA. Scan only the commits actually being pushed. Scanning full history on every push is slow and re-flags secrets you have already triaged.

#!/usr/bin/env sh
# .husky/pre-push
set -eu

z40=0000000000000000000000000000000000000000
SCAN_LOG=$(mktemp)
STATUS=0

while read -r local_ref local_sha remote_ref remote_sha; do
  # Skip branch deletions β€” local SHA is all zeros
  [ "$local_sha" = "$z40" ] && continue

  if [ "$remote_sha" = "$z40" ]; then
    # New remote branch: scan every commit not yet on any remote
    RANGE="$local_sha --not --remotes=origin"
  else
    # Existing branch: scan only the new commits
    RANGE="${remote_sha}..${local_sha}"
  fi
done

Verify the range resolves to exactly the outbound commits before wiring in the scanner:

# Lists the commits that would be scanned β€” no more, no less
git log --oneline origin/main..HEAD

Step 3 β€” Scan only the outbound commit range and abort on a hit Jump to heading

Feed the range to gitleaks git with --log-opts. A non-empty result must exit non-zero so Git aborts the push. gitleaks already returns a non-zero code when it finds a leak, so the hook simply propagates it.

  # (inside the while loop, after RANGE is set)
  echo "β†’ Scanning $RANGE for secrets..."
  # --no-banner: quiet output   --redact: never print the secret value itself
  # --log-opts: restrict the scan to the outbound commit range
  if ! gitleaks git . \
        --no-banner \
        --redact \
        --config .gitleaks.toml \
        --log-opts="$RANGE" >>"$SCAN_LOG" 2>&1; then
    STATUS=1
  fi
done

if [ "$STATUS" -ne 0 ]; then
  echo "ERROR: potential secret detected in the outbound commits. Push aborted." >&2
  cat "$SCAN_LOG" >&2
  echo "If this is a false positive, allowlist it in .gitleaks.toml (see docs)." >&2
  rm -f "$SCAN_LOG"
  exit 1
fi
rm -f "$SCAN_LOG"
exit 0

Verify the hook blocks a real leak by planting a synthetic one on a throwaway branch:

# Create a decoy secret, commit it, then run the hook against the range
git switch -c test/leak
printf 'aws_secret_access_key=AKIAIOSFODNN7EXAMPLE\n' > leak.txt
git add leak.txt && git commit -m "test: decoy secret"
echo "refs/heads/test/leak $(git rev-parse HEAD) refs/heads/test/leak $z40" | sh .husky/pre-push
# Expect: non-zero exit and a redacted finding. Clean up afterwards:
git switch - && git branch -D test/leak

Step 4 β€” Allowlist false positives in a committed config Jump to heading

Real projects contain example keys, test fixtures, and documentation samples that trip the scanner. Suppress them narrowly in a committed .gitleaks.toml so the allowlist is reviewed like any other change β€” never by disabling a whole rule.

# .gitleaks.toml β€” extends the built-in ruleset
[extend]
useDefault = true

[allowlist]
description = "Reviewed non-secrets: fixtures and documented examples"
# Prefer specific paths and commit-pinned regexes over broad patterns
paths = [
  '''(^|/)testdata/''',
  '''(^|/)docs/examples/''',
]
regexes = [
  # The canonical AWS example key from public documentation
  '''AKIAIOSFODNN7EXAMPLE''',
]
# Pin a known-safe finding by its commit to avoid a blanket regex
commits = [
  "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
]

Verify the allowlist neutralizes a known false positive while a genuine pattern still trips:

# Scans the working tree against the config β€” should report no leaks
gitleaks dir . --config .gitleaks.toml --no-banner

SAFETY WARNING: Never widen the allowlist to silence a real finding. Suppressing a live credential in .gitleaks.toml hides it from every future scan and guarantees it ships. Allowlist only values you have personally confirmed are non-secret (documented examples, fixtures), and pin them by path or commit rather than a broad regex.

Step 5 β€” Mirror the same scan in CI as the authoritative gate Jump to heading

The hook is advisory β€” it is skippable with --no-verify and is not installed on every clone. Make CI the gate that cannot be bypassed, running the same .gitleaks.toml so local and remote agree. Scan the pushed range on branches and the full history on the default branch to catch anything a bypass slipped through.

# .github/workflows/secret-scan.yml
name: secret-scan
on:
  push:
    branches: ["**"]
  pull_request:

jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # Full history so a scan can find secrets in older commits
          fetch-depth: 0
      - name: Run gitleaks (pinned)
        uses: gitleaks/gitleaks-action@v2
        env:
          GITLEAKS_CONFIG: .gitleaks.toml
          # Fail the job on any unallowlisted finding
          GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "false"

Verify the CI gate fails closed by pushing the decoy branch from Step 3 to a fork or scratch remote and confirming the job turns red. Then make the check required on protected branches so a leaked secret cannot merge β€” the same enforcement pattern used for Commit Signing & Git Supply-Chain Security.

# Confirm the workflow is registered and named as expected
gh workflow list | grep secret-scan

Step 6 β€” Distribute the hook and document the bypass Jump to heading

Managed with Husky, npm install installs the hook for every contributor through the prepare lifecycle β€” no separate setup script. See Local Hook Configuration with Husky for the full pattern, including how core.hooksPath is wired.

# One-time, in a Husky-managed repo
npx husky init
# The scanning script from Steps 2–3 lives at .husky/pre-push
chmod +x .husky/pre-push
git add .husky/pre-push .gitleaks.toml

The emergency bypass stays available but must be a deliberate, auditable act β€” and it never reaches CI, which still scans:

git push --no-verify

SAFETY WARNING: If a secret has already been pushed, removing it in a new commit is not a fix β€” the value lives on in history and any clone. Rotate the credential at its source immediately (revoke and regenerate the token, key, or password), then purge the blob from history with git filter-repo --invert-paths --path <leaked-file> (or --replace-text) and force-push. Notify anyone who may have fetched. Rotation is mandatory; history rewrite alone does not make an exposed secret safe.

Validation checklist Jump to heading

Before considering this scan production-ready, confirm each item:

Frequently asked questions Jump to heading

Why scan in a pre-push hook if CI already scans? Jump to heading

The pre-push hook is a fast local warning that stops a secret before it ever leaves the machine, when removal is still trivial β€” no rotation, no history rewrite, no incident. CI is the authoritative gate because hooks are skippable with --no-verify and are not installed on every clone or automation path. Run both layers: the hook for sub-second feedback in the developer’s flow, and the required CI check to enforce a policy no one can bypass.

A secret already reached the remote. Is deleting the file in a new commit enough? Jump to heading

No. The instant a secret is pushed, treat it as compromised and rotate it at the source β€” revoke and regenerate the token, key, or password. A follow-up commit that deletes the file leaves the original value in history, recoverable by anyone with clone or fork access, including forks and CI caches you do not control. Rotate first, then rewrite history with git filter-repo to purge the blob and force-push. History rewriting cleans the record; only rotation makes the exposed credential worthless.

gitleaks or trufflehog β€” which should the hook use? Jump to heading

Use gitleaks in the hook: a single static binary with fast regex-plus-entropy scanning of a commit range and a committed TOML allowlist, all with no network dependency β€” exactly what a push-blocking gate needs. trufflehog’s strength is live verification (actually calling the provider to see if a credential is active), which is valuable in CI but too slow and network-dependent to sit in the push path. A common split is gitleaks locally and either scanner in CI, as long as both enforce the same policy.