Prevention guide
How to set up pre-commit secret scanning (and make it stick for your whole team)
Scanning git history for secrets is the remediation step. A pre-commit hook is the prevention step: it runs before the commit is recorded, so the secret never enters git history in the first place — not locally, not in CI, not on the remote. This guide covers three setup approaches and the team rollout problem that most guides skip entirely.
- Why prevention beats remediation
- Choosing an approach
- pre-commit framework
- Husky (Node.js projects)
- Raw git hook
- Suppressing false positives
- Making it mandatory for the whole team
- What the hook doesn't cover
- FAQ
Why prevention beats remediation
Once a secret is committed — even to a local branch that you have never pushed — the risk profile changes. The secret is now in the git object store. Pushing it, even accidentally, broadcasts it to GitHub where bots watch the public events API and harvest new commits within seconds. Removing it from the working tree does not remove it from history: anyone with a clone can run git log -p and read the original value.
History rewriting (git-filter-repo, BFG Repo-Cleaner, force-push, asking collaborators to re-clone) is time-consuming and disruptive. Rotation at the provider is mandatory but sometimes painful — especially for long-lived service-account credentials or webhook signing secrets that are wired into production infrastructure. A pre-commit hook short-circuits all of that by rejecting the commit before the secret is recorded.
The cost of the hook is a sub-second scan on each commit. The cost of not having it is a potential incident response.
Choosing an approach
There are three common setups, each with a different tradeoff on setup effort and team shareability:
pre-commit framework
Hook config lives in .pre-commit-config.yaml, which is committed to the repo. Every developer runs pre-commit install once. Works for any language stack. Requires Python (or Homebrew).
Husky
Hook config is installed via npm, shareable through package.json. No Python required. Runs automatically when teammates run npm install (with the prepare script). Best fit for JavaScript/TypeScript projects.
Raw git hook
A shell script in .git/hooks/pre-commit. Zero additional tools required beyond gitleaks itself. The catch: .git/hooks/ is not tracked by git, so every developer must install it manually.
| Approach | Committed to repo? | Auto-installs for teammates? | Extra deps |
|---|---|---|---|
| pre-commit framework | Yes (.pre-commit-config.yaml) | No — each dev runs pre-commit install | Python or Homebrew |
| Husky | Yes (.husky/pre-commit) | Yes — triggers on npm install via prepare | Node.js / npm |
| Raw git hook | No (.git/hooks/ is excluded) | No — manual per-developer | None beyond gitleaks |
Option A: pre-commit framework (recommended for most teams)
The pre-commit framework manages hooks as versioned, shareable configuration. The hook definition lives in .pre-commit-config.yaml, a normal file that is committed to the repository and reviewed like any other code change.
Step 1: Install gitleaks
Each developer needs gitleaks available on their PATH. The pre-commit framework can download and cache it automatically, so developers do not need to install gitleaks manually if they use the framework approach — but having a local copy is useful for ad-hoc checks.
# macOS (Homebrew)
brew install gitleaks
# Linux
curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz \
| tar -xz -C /usr/local/bin gitleaks
# Verify
gitleaks version
Step 2: Install the pre-commit framework
# macOS (Homebrew — no Python needed)
brew install pre-commit
# Or with pip
pip install pre-commit
# Verify
pre-commit --version
Step 3: Add the gitleaks hook to your repo
Create or edit .pre-commit-config.yaml at the root of the repository:
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.0 # pin to the latest release tag
hooks:
- id: gitleaks
Check the gitleaks releases page for the current latest tag and update rev to match. Pinning to a specific tag (not HEAD) means the hook version is deterministic for everyone on the team.
Step 4: Install the hook into your local git repo
pre-commit install
This writes a hook script to .git/hooks/pre-commit that delegates to the pre-commit framework. Each developer on the team must run this command once after cloning the repository. The framework handles downloading and caching the gitleaks binary automatically — developers do not need to install gitleaks themselves.
Step 5: Test it
# Stage a file that contains a test secret pattern
echo "STRIPE_SECRET_KEY=sk_live_FAKEKEY123" > test-secret.txt
git add test-secret.txt
# Run pre-commit manually against staged files
pre-commit run --files test-secret.txt
You should see gitleaks abort with a finding. Clean up: git restore --staged test-secret.txt && rm test-secret.txt.
pre-commit autoupdate periodically to bump each hook's rev to the latest tag. Review the diff before committing — this is when you pick up new gitleaks detection patterns.
Option B: Husky (Node.js / npm projects)
Husky is the standard hook manager for JavaScript and TypeScript projects. It stores hook scripts in a .husky/ directory that IS tracked by git, and wires installation into npm's prepare lifecycle — so the hook is installed automatically when a developer runs npm install. No separate pre-commit install step required.
Step 1: Install gitleaks
Each developer needs gitleaks on their PATH (see install commands above). For CI, use the gitleaks GitHub Action (covered in the detection guide).
Step 2: Add Husky to the project
# Install Husky as a dev dependency
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory and adds prepare script)
npx husky init
This adds "prepare": "husky" to package.json and creates a sample .husky/pre-commit file. The prepare script runs automatically when anyone installs dependencies with npm install.
Step 3: Set the pre-commit hook to run gitleaks
Edit .husky/pre-commit to contain:
#!/bin/sh
gitleaks protect --staged -v
gitleaks protect --staged scans only the staged diff — the same diff git is about to record — so it is fast and will not block commits for changes that contain no secrets.
Step 4: Commit the Husky files
git add .husky/pre-commit package.json
git commit -m "chore: add gitleaks pre-commit hook via husky"
After this commit is pushed, any developer who pulls the branch and runs npm install will automatically have the hook installed. No manual step required.
prepare script. Most CI runners set NODE_ENV=production, which causes npm to skip the prepare script and the hook is not installed. This is expected — Husky is for local commits, not CI. Use the gitleaks GitHub Action in CI alongside Husky locally.
Option C: raw git hook (zero dependencies beyond gitleaks)
If you do not want to add the pre-commit framework or Husky, you can write the hook directly. This requires nothing beyond gitleaks itself, but the hook lives in .git/hooks/ which is excluded from git tracking — every developer must run the install command manually.
Install the hook
# Run from the root of the repository
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
gitleaks protect --staged -v
if [ $? -ne 0 ]; then
echo ""
echo " gitleaks found a potential secret in the staged diff."
echo " Remove or move the secret to an environment variable, then commit again."
echo " To bypass (not recommended): git commit --no-verify"
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
The hook runs gitleaks protect --staged. If it finds a pattern match, it exits with code 1, which causes git to abort the commit and print the error. The --no-verify escape hatch note is intentional: developers should know it exists so they can understand what the error message means, but it should be treated as a last resort, not a habit.
Sharing the raw hook with your team
Because .git/hooks/ is not tracked, the standard workaround is to commit the hook script to a different location in the repository and add an install step to your setup documentation:
# Commit the hook to a tracked location
mkdir -p scripts/hooks
cp .git/hooks/pre-commit scripts/hooks/pre-commit
git add scripts/hooks/pre-commit
# Document the setup step (in README or CONTRIBUTING.md)
# Run after cloning:
cp scripts/hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
This works, but it requires every developer to take a manual step. For teams, the pre-commit framework or Husky approach is more reliable precisely because they do not depend on every developer remembering.
Suppressing false positives with an allowlist
Test fixtures, documentation examples, and SDK sample code often contain strings that match secret patterns (e.g. sk_test_, EXAMPLE_KEY, your_token_here). Rather than bypassing the hook with --no-verify, add a .gitleaks.toml file that tells gitleaks what to ignore.
Example .gitleaks.toml
[extend]
# Use the default gitleaks ruleset as the base; only extend/override here
useDefault = true
[[allowlist]]
description = "Test fixtures and documentation examples"
paths = [
'''tests/fixtures/''',
'''docs/examples/''',
'''__mocks__/''',
]
regexes = [
'''EXAMPLE_KEY''',
'''sk_test_[a-zA-Z0-9]{24}TESTONLY''',
'''your[-_]?token[-_]?here''',
]
[[allowlist.commits]]
# Exempt a specific historical commit SHA that contains a known false positive
# (use this sparingly — by SHA rather than by path to stay precise)
# description = "Initial commit with placeholder credentials in setup guide"
# commit = "a1b2c3d4e5f6..."
Place .gitleaks.toml at the repository root and commit it. Both gitleaks protect (the hook) and gitleaks detect (full history scan) read the same config file, so adding an allowlist entry once covers both use cases.
config/ because it contains a fixture file will also suppress findings for any real secrets staged from that directory. Prefer regex-based allowlists that match placeholder patterns specifically, rather than broad path exclusions.
Making it mandatory for the whole team
No hook-based approach is truly mandatory, because any developer can commit with git commit --no-verify. The practical strategies for enforcing compliance are:
- Add a CI scan that blocks merges. A GitHub Actions job that runs gitleaks on every pull request turns the hook into a hard gate at the repository level. A developer who bypasses the hook locally will fail CI when they push. This is the most important layer — see the CI setup section in the detection guide for the exact workflow YAML.
- Use the pre-commit framework or Husky rather than a raw hook. Both reduce the "I forgot to install it" scenario significantly — the pre-commit framework makes installation a one-command step, and Husky makes it automatic on
npm install. - Add a setup check to your onboarding script. If your project has a
make setup,./scripts/bootstrap.sh, or similar onboarding script, add a step that runspre-commit install(or equivalent) automatically. New developers will have the hook without knowing they need it. - Use a
pre-commit run --all-filesCI check. In addition to the gitleaks GitHub Action, running the full pre-commit suite in CI (not just gitleaks, but all configured hooks) confirms that the repository-wide hook config is passing and gives you a consistent CI/local parity signal.
git diff --cached into LeakCheck for a zero-install browser scan. This is especially useful when you are on a new machine without gitleaks installed yet, or when you want a second opinion on a pattern gitleaks did not flag.
What the hook doesn't cover
A pre-commit hook scanning gitleaks protect --staged has a precise scope: it reads the output of git diff --cached at the moment git commit is called. Several scenarios fall outside that scope:
| Scenario | Does the hook catch it? | What does? |
|---|---|---|
| Secret in staged lines being committed now | Yes | The hook |
| Secret in an unstaged modification to a tracked file | No — unstaged changes are not in the diff | Paste into LeakCheck; run gitleaks detect |
| Secret that was committed in a previous commit (local or remote) | No — the hook only sees new staged changes | gitleaks detect --log-opts="--all" |
Secret committed with git commit --no-verify | No — hook is bypassed entirely | CI scan (GitHub Actions gitleaks job) |
| Secret in a file that was committed by a GUI client that does not run hooks | No | CI scan on every push |
| Secret embedded in a binary, image, or compiled artifact | Partial — depends on gitleaks' ability to read the format | Manual review; ExposureCheck post-deploy |
The complete prevention stack is: hook (pre-commit) + full-history scan (detect) + CI enforcement (GitHub Actions) + post-deploy scan (ExposureCheck). Each layer catches what the others miss. The hook is the fastest and highest-value layer to add first because it blocks the most common path for secrets to escape — a developer committing a file they forgot to sanitize.
If the hook finds nothing but you want to confirm nothing slipped through on the current branch's full history, run the detection guide's CLI commands against the branch:
gitleaks detect --source . --log-opts="main..HEAD"
Full details on history scanning, CI integration, and what to do when a secret is found are in the companion guides:
Detection guide: scan a git repository for secrets →
Fix guide: accidentally committed API key to GitHub →
Frequently asked questions
What happens if a developer runs git commit --no-verify?
The hook is bypassed entirely. --no-verify skips all pre-commit and commit-msg hooks for that commit. This is the single most important limitation of hook-based approaches — the protection is advisory, not enforced by git itself. The reliable defence is a CI check that runs on every push to the remote. A developer who bypasses the hook will fail the CI scan when their branch is pushed, before it can be merged. See the GitHub Actions setup in the detection guide.
Does gitleaks protect scan the whole file or just the staged lines?
Only the staged diff — the output of git diff --cached. Lines that exist in the file but are not part of the staged change are not examined. This means secrets that were already in the file from a previous commit are not re-flagged on every commit (which would make the hook unusable). For checking the full content of a file, paste it into LeakCheck or run gitleaks detect against the full history.
Will the hook slow down commits noticeably?
No, for almost all projects. gitleaks protect scans only the staged diff, not the whole repository, so scan time scales with the size of the staged change rather than the size of the codebase. A typical staged diff completes in under a second. Very large initial commits (adding thousands of files at once) may take a few seconds. The pre-commit framework adds a small overhead on the first run while it downloads the gitleaks binary, but subsequent runs use a cached version.
What is the difference between the pre-commit framework and a raw git hook?
A raw git hook script lives in .git/hooks/, which git does not track — the directory is excluded from the repository's object store. Every developer must install the hook manually, and there is no built-in mechanism to keep versions consistent across the team. The pre-commit framework stores hook config in .pre-commit-config.yaml, which is a normal committed file. Once a developer runs pre-commit install, the framework reads the config file and keeps the hook in sync automatically. For a team of more than one person, the framework or Husky approach is the right choice.
Does this work with GitHub Desktop, Tower, Sourcetree, or other GUI git clients?
It depends. GitHub Desktop does not run pre-commit hooks at all — commits made through GitHub Desktop bypass every hook. Tower runs hooks by default. Sourcetree requires enabling hooks in Settings → Git → "Run pre-commit and commit-msg hooks." For teams using mixed clients, assume some developers will bypass hooks, and ensure the CI scan is the authoritative gate.
Can I use this with a monorepo?
Yes. gitleaks protect scans the staged diff from wherever you run it — if you run git commit from the monorepo root, it scans all staged files across all packages in that diff. If your monorepo uses git worktrees (each package has its own .git), you need the hook installed separately in each worktree. The .gitleaks.toml allowlist applies to any path within the repo, so a single allowlist at the root covers all packages.
My test fixtures contain example API keys that trigger false positives — how do I allow them?
Create a .gitleaks.toml at the repository root with an [[allowlist]] block. You can exempt specific directories (tests/fixtures/), regex patterns that match placeholder strings (EXAMPLE_KEY, sk_test_), or specific historical commit SHAs. Commit the config file so the allowlist is consistent for everyone on the team. The gitleaks configuration docs cover the full allowlist schema. Avoid broad path exclusions on directories that also contain real credentials — prefer regex patterns that match only the placeholder strings specifically.
Also in the Copper Bay Labs ship-safety suite
Secret prevention is one layer. These free tools cover the rest of the pre- and post-deploy security stack:
Scan your live URL for exposed .env files, .git directories, source maps, and bundled secrets. Free, no signup.
Check your security headers — CSP, HSTS, X-Frame-Options, and more — so your site does not become an XSS vector.
Dependencies DepCheckScan your package.json for vulnerable, outdated, and abandoned npm packages before they reach production.