Set Up Secret Scanning in Pre-Commit and CI, Then Respond Safely
For developers who need secret scanning to block bad commits locally and in CI without slowing delivery. You’ll leave with a working pre-commit hook, a CI job that fails on detected secrets, and a concrete playbook for rotating and removing anything the scanners find.
TL;DR — Add a local pre-commit hook with gitleaks so secrets are blocked before they enter history, then run the same scanner in CI against the full repo history. If a scan finds something real, treat it as compromised immediately: rotate the credential first, then remove it from the repo or history, and only use an allowlist for verified false positives. Reading time: ~5 min
Goal
When you are done, git commit will fail locally if staged changes contain likely secrets, your CI pipeline will fail on detected secrets in the repository history, and you will have a repeatable response path for rotating, removing, and documenting findings.
Prerequisites
- Git access to the repository and permission to push branches and tags
- Ability to rotate any credentials your codebase might contain: cloud IAM, API providers, database users, SMTP, etc.
git >= 2.39— check with:
git --version
pre-commit >= 3.7— check with:
pre-commit --version
python >= 3.10if you installpre-commitwith pip — check with:
python3 --version
gitleaks >= 8.18— check with:
gitleaks version
- A CI system that can run shell commands on pull requests and default-branch pushes
- A short list of paths that legitimately contain test fixtures or fake tokens, if any
Steps
Step 1: Install pre-commit and gitleaks locally
Run one of these install paths.
python3 -m pip install --user pre-commit
brew install gitleaks
Linux binary install example:
curl -sSL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
sudo tar -C /usr/local/bin -xzf /tmp/gitleaks.tar.gz gitleaks
chmod +x /usr/local/bin/gitleaks
gitleaks version
What you should see when this succeeds: pre-commit --version and gitleaks version both print a version string and exit 0.
Step 2: Add the pre-commit hook config
Create .pre-commit-config.yaml at the repo root with this exact content.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks
args: ["protect", "--staged", "--verbose"]
Install the hook:
pre-commit install
pre-commit install --hook-type commit-msg
What you should see when this succeeds: pre-commit installed at .git/hooks/pre-commit.
Step 3: Add a repo-level gitleaks config for ignores you can defend
Create .gitleaks.toml with only explicit allow rules. Start small.
title = "repo gitleaks config"
[allowlist]
description = "documented false positives only"
paths = [
'''(^|/)testdata/''',
'''(^|/)fixtures/'''
]
regexes = [
'''example[_-]?token''',
'''dummy[_-]?secret'''
]
Test the config against the current tree:
gitleaks detect --source . --config .gitleaks.toml --no-git --verbose
What you should see when this succeeds: either no leaks found with exit code 0, or a list of findings you can inspect before committing.
Step 4: Verify the hook blocks a bad commit
Create a throwaway file with a fake AWS-style key pattern and try to commit it.
printf 'AWS_ACCESS_KEY_ID=AKIA1234567890ABCDEF\n' > /tmp/secret-test.env
cp /tmp/secret-test.env ./secret-test.env
git add secret-test.env
git commit -m "test secret scanner"
Typical failure output shape:
gitleaks................................................................Failed
- hook id: gitleaks
- exit code: 1
○
│╲
│ ○
○ ░
░ gitleaks
Finding: AWS_ACCESS_KEY_ID=AKIA1234567890ABCDEF
Secret: AKIA1234567890ABCDEF
RuleID: aws-access-token
File: secret-test.env
Line: 1
Commit: staged changes
Entropy: 3.88
Clean up the test file:
git restore --staged secret-test.env
rm -f secret-test.env
What you should see when this succeeds: the commit is blocked with exit code 1 and the file path and line number are shown.
Step 5: Add CI scanning for full history
Use the same scanner in CI, but scan git history instead of only staged files. In your CI config, add a job that runs these exact commands after checkout with full history.
git fetch --prune --unshallow || true
gitleaks detect --source . --config .gitleaks.toml --verbose
If your CI does shallow clones by default, set checkout depth to full in that system’s checkout step. For example, in any provider that supports it, use fetch depth 0 or disable shallow clone.
What you should see when this succeeds: the job exits 0 on clean repos; on findings it exits 1 and prints file, commit, and rule details.
Step 6: Respond correctly when a real secret is found
⚠️ If the secret is valid, assume it is already compromised. Rotating after cleanup is too late; rotate first so any copied value stops working.
- Identify the credential type from the finding and rotate it in the issuing system.
Examples:
- Cloud access key: create a new key, deploy it, disable the old one, then delete the old one.
- Database password: create a new password or user, update app config, reload the app, revoke the old credential.
- API token: generate a replacement token with the same scopes, deploy it, revoke the old token.
- Remove the secret from the current branch if it is only in unmerged commits:
git rm --cached path/to/file-with-secret || true
$EDITOR path/to/file-with-secret
git add path/to/file-with-secret
git commit -m "remove secret from tracked files"
- If the secret exists in published history, rewrite history with
git-filter-repo.
python3 -m pip install --user git-filter-repo
printf 'literal:OLD_SECRET_VALUE==>REMOVED\n' > /tmp/replacements.txt
git filter-repo --replace-text /tmp/replacements.txt
Force-push rewritten refs:
git push --force --all
git push --force --tags
- Invalidate local clones that still contain the old history. Tell the team to reclone or hard-reset to the new history.
What you should see when this succeeds: the rotated credential no longer authenticates, the scanner no longer reports the secret in current content or history, and collaborators can pull the rewritten branch state.
Verify it works
Run the local hook against all files:
pre-commit run --all-files
Expected success shape:
gitleaks................................................................Passed
Run a full-history scan locally:
gitleaks detect --source . --config .gitleaks.toml --verbose
printf 'exit=%s\n' "$?"
Expected success shape:
○
│╲
│ ○
○ ░
░ gitleaks
no leaks found
exit=0
If you rewrote history, verify the old value is gone:
git log -S 'OLD_SECRET_VALUE' --all --oneline
git grep -n 'OLD_SECRET_VALUE' $(git rev-list --all) || true
Expected success shape: no matching commits from git log -S, and no grep output.
Common pitfalls
The CI job scans only the shallow checkout
Mistake: running gitleaks detect in CI without fetching full history.
Symptom: CI passes, but a local full-history scan finds old secrets; or the scanner reports only current files and misses earlier commits.
One-line fix: add git fetch --prune --unshallow || true before gitleaks detect and set checkout depth to full.
Developers bypass the hook with --no-verify
Mistake: relying on pre-commit alone.
Symptom: a bad commit lands in a branch even though local hooks are configured.
One-line fix: keep the local hook for fast feedback, but enforce the same scan in CI and block merges on CI failure.
The allowlist hides real secrets
Mistake: adding broad regexes like .*token.* or ignoring whole source directories.
Symptom: scanner goes quiet, but valid credentials remain in the repo.
One-line fix: replace broad allow rules with exact paths for fixtures and exact dummy-value regexes only.
The secret was removed from the latest file but still exists in history
Mistake: deleting the line and committing the change without rewriting history.
Symptom: gitleaks detect still fails and points to an older commit hash.
One-line fix: rotate the credential, then run git filter-repo --replace-text and force-push rewritten refs.
The team keeps pulling the old leaked history back in
Mistake: rewriting history, but collaborators continue working from stale clones.
Symptom: the secret reappears in later pushes or CI starts failing again after being clean.
One-line fix: tell everyone to reclone or run git fetch --all plus git reset --hard origin/<branch> before pushing.
The finding is in generated files or vendored code
Mistake: scanning build output, package caches, or vendored dependencies as if they were first-party code.
Symptom: noisy findings in dist/, .venv/, vendor/, or lockfile-adjacent artifacts.
One-line fix: scan the repo root but add path-based allow rules for generated directories that are committed intentionally, or stop committing those directories.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI