Catch secrets before commit with gitleaks and pre-commit

Block credentials at the git hook, scan history for what already leaked, and wire the same check into CI.

Feb 25, 2025·Updated ·6 min readBeginner·By SecOpsLog · command-tested
git — representative: the commit that did not happen, as the pre-commit framework reports it
git add config/prod.env && git commit -m "prod config"
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks
- exit code: 1
nothing was written to history; the key is still only in the working tree. The hook ran the command in the next block
bash — observed: gitleaks 8.30.1 on the staged key, with the exact command the upstream hook runsobserved
gitleaks git --pre-commit --redact --staged --verbose --exit-code 1 .
Finding: AWS_ACCESS_KEY_ID=REDACTED
RuleID: aws-access-token
File: config/prod.env
Line: 4
Fingerprint: config/prod.env:aws-access-token:4
INF 0 commits scanned.
WRN leaks found: 1
--redact prints REDACTED (a partial view is --redact=50). The same command with the key in the working tree but not staged scanned nothing and exited 0

A secret that reaches a commit has to be rotated, because a clone, a fork, a CI cache or a mirror may already hold it, and rewriting history removes it from none of those. The cheap outcome is the one above: the hook ran on the staged diff, the commit was refused, and the only cleanup is deleting a line. gitleaks is the scanner in all three places this post covers, and since v8.19 its commands are git, dir and stdin; detect and protect still run but are deprecated, and most copied snippets use them.

The hook: pinned, and easy to bypass by design

.pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1 # a tag; pre-commit autoupdate bumps it in a reviewable diff
hooks:
- id: gitleaks # upstream entry: gitleaks git --pre-commit --redact --staged --verbose

The upstream hook definition already passes --pre-commit --staged --redact, so the id alone is the whole configuration, and adding args duplicates or contradicts it. pre-commit install writes .git/hooks/pre-commit once per clone, which is the hook's limit: a developer who never ran it, a machine set up last year, or SKIP=gitleaks git commit (a documented, legitimate bypass for a known false positive) all skip it. The hook is for fast feedback at the keyboard. Enforcement lives in the next section, and treating the hook as enforcement is the mistake that makes teams surprised when CI finds a key. One detail for anyone testing the hook: the access key id from the AWS documentation, AKIAIOSFODNN7EXAMPLE, is allowlisted by the default rules and produces no finding, so a test with it proves nothing; use a made-up id with the same shape.

CI is the gate, with the same version and config

.gitlab-ci.yml
secret_scan:
stage: test
image:
name: ghcr.io/gitleaks/gitleaks:v8.30.1
entrypoint: [""]
variables:
GIT_DEPTH: 0 # the git command needs history, not a shallow clone
script:
- gitleaks git --redact --exit-code 1 --report-format sarif --report-path gitleaks.sarif
--log-opts="$CI_MERGE_REQUEST_DIFF_BASE_SHA..HEAD" .
artifacts:
when: always
paths: [gitleaks.sarif]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"

--log-opts narrows the scan to the commits the merge request adds, which keeps the job fast and keeps a decade of history from failing every pipeline. The image tag and the hook's rev should match, and a .gitleaks.toml in the repository root is picked up by both automatically, so a rule tuned for the hook behaves the same in CI. Required on protected branches, this job is what makes --no-verify pointless. GitLab's own Secret Detection template is the alternative for teams that want the findings in the security dashboard rather than as an artifact; it runs a different engine, so keep one or the other as the gate.

bash — observed: the CI range scan on a branch where the key was committed with --no-verifyobserved
gitleaks git --redact --exit-code 1 --log-opts="main~1..HEAD" --report-format sarif --report-path gitleaks.sarif --verbose .
Finding: AWS_ACCESS_KEY_ID=REDACTED
RuleID: aws-access-token
File: config/prod.env
Line: 4
Commit: 55cc5b0668ff7d711be3853f80426b87dc6f52f6
Fingerprint: 55cc5b0668ff7d711be3853f80426b87dc6f52f6:config/prod.env:aws-access-token:4
INF 2 commits scanned.
WRN leaks found: 1
gitleaks git --redact --exit-code 1 --log-opts="HEAD~1..HEAD" .
INF 1 commits scanned.
INF no leaks found
exit 1 then exit 0: the range decides. A range that starts after the offending commit finds nothing, which is why the job uses the merge request base, not HEAD~1

History: scan once, baseline, then only new findings

bash — representative: the first full scan of a real history, and its triage
gitleaks git --redact --report-path gitleaks-baseline.json .
WRN leaks found: 23
jq -r ".[] | [.RuleID, .File, .Date[:10]] | @tsv" gitleaks-baseline.json | sort | uniq -c | sort -rn | head -3
11 generic-api-key test/fixtures/keys.json 2021-03-02
7 aws-access-token scripts/deploy-old.sh 2019-11-14
5 private-key docker/dev-cert.pem 2020-06-30
twenty-three findings is a typical first run on an old repository; the fixture history below holds one
bash — observed: the report used as --baseline-path, then a token committed after itobserved
gitleaks git --redact --exit-code 1 --report-path gitleaks-baseline.json .; echo exit=$?
WRN leaks found: 1
exit=1
gitleaks git --redact --exit-code 1 --baseline-path gitleaks-baseline.json .; echo exit=$?
INF 3 commits scanned.
INF no leaks found
exit=0
git add config/ci.env && git commit -qm "ci config" # a token committed after the baseline
gitleaks git --redact --exit-code 1 --baseline-path gitleaks-baseline.json --verbose .
RuleID: github-pat
File: config/ci.env
INF 4 commits scanned.
WRN leaks found: 1
the baseline is a normal report; anything in it is ignored, anything new is not

Twenty-three findings on a first run is normal, and they make a triage list rather than twenty-three incidents. Fixtures and test keys get a gitleaks:allow comment on the line, or a fingerprint in .gitleaksignore, each with a reason in the commit that adds it. The two waivers have different scopes, and the run showed the difference: a fingerprint taken from a git scan carries the commit hash (55cc5b06…:config/prod.env:aws-access-token:4) and silences that finding in history scans, but a gitleaks dir scan of the working tree produces the same finding with a path-only fingerprint and reports it again; the gitleaks:allow comment silences both, and --ignore-gitleaks-allow on the CI job brings it back on demand. The real keys are assumed compromised and rotated in the system that issued them (IAM, the Git host, Vault), and the ticket for each records the date the key first appeared, which the report gives you. History rewriting is a separate decision for repositories that must be published; it shrinks the exposure surface and does not undo it.

Recovering from a false positive or a lost key, in order

SituationDoDo not
the hook blocks a test fixturegitleaks:allow on the line with a reason in the commit; or SKIP=gitleaks git commit once, and let the CI job (same config) confirm itgit commit --no-verify as a habit: the CI job catches it and the merge request goes red anyway
a real key reached a commitrotate it where it was issued first; then remove the line, and record the fingerprint in .gitleaksignore only after rotation so the scan history stays honestrewrite history as the fix: every clone, fork and cache still has the key
the baseline hid a finding it should not havegit checkout <sha> -- gitleaks-baseline.json to the previous report, or delete the entry from the JSON array; the finding fails the next runregenerate the baseline on every pipeline: it absorbs every new key
What was run for this article
gitleaks 8.30.1 (the ghcr.io/gitleaks/gitleaks:v8.30.1 image, Docker Engine 28.5.2, linux/arm64) against a five-commit repository built for the run with one made-up AWS access key id and one made-up GitHub token; neither was ever issued. The terminal blocks marked observed are copied from that run (commit hashes are from the recorded run; the fixture rebuilds the history each time). Twelve exit codes are asserted: staged and unstaged pre-commit, the AWS documentation key, two --log-opts ranges, report and --baseline-path, a .gitleaksignore fingerprint against git and dir scans, gitleaks:allow and --ignore-gitleaks-allow. The pre-commit framework and the GitLab job were not executed; their lines are representative. In the recovery table, the gitleaks:allow and baseline rows are what the run showed; SKIP=gitleaks, rotation and the history rewrite are not executed here. The Finding lines are quoted as a colour terminal shows them; with --no-color, 8.30.1 prints only REDACTED on that line.
An allowlist is a firewall rule
A .gitleaks.toml allowlist with a broad regex or a whole directory silences the scanner for everything under it, forever, invisibly. Prefer per-line gitleaks:allow comments and per-finding fingerprints in .gitleaksignore, review additions to either in the merge request that adds them, and reject a rule nobody can explain in one sentence.

Scanning finds secrets that should not be in Git; it does not decide where they should be. Values that belong next to the code go in encrypted with SOPS and age, and the ones CI needs at runtime come from the pipeline's secret store or an OIDC exchange, so there is nothing for the scanner to find.

Related posts

Quick reference