Leaked credentials are one of the most common breach causes. Block them before they ever reach history with pre-commit hooks, then backstop with a CI scan and a plan for the secret that slips through.
A committed AWS key, database password, or API token is a self-serve breach: attackers scan public and internal repos continuously and use leaked keys within minutes. Once a secret is in git history, deleting the line does not remove it — it lives in every clone forever. So the goal is prevention (never commit it) with detection (catch it if you do) as the backstop.
The lifecycle of a leaked secret:
developer pastes key in config -> commits -> pushes
-> bots find it in <5 min -> key is used -> incident
Two hard truths:
1. "I removed it in the next commit" does NOT help — it's in
history and in every clone. It must be ROTATED (revoked).
2. Prevention >> cleanup. Blocking the commit costs seconds;
rotating prod credentials and doing IR costs days.
Defense in layers: pre-commit (block) + CI scan (catch) +
rotation runbook (contain).The cheapest place to stop a secret is on the developer's machine, before the commit exists. gitleaks scans the staged diff; the pre-commit framework wires it (and other checks) to run automatically on git commit. This gives instant feedback and keeps the secret out of history entirely.
# .pre-commit-config.yaml — runs on every 'git commit'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks # scans the staged diff for secrets
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: detect-private-key
- id: check-added-large-files
# install once per clone:
# pip install pre-commit && pre-commit install
# now a commit containing an AWS key is rejected locally, instantly.Pre-commit hooks can be skipped (--no-verify) or simply not installed, so they cannot be your only defense. Run the same scan in CI where it is mandatory. Scanning the full history on a schedule catches secrets that predate the hook; scanning the PR diff keeps every new change clean.
# job in .github/workflows/security.yml
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so we can scan all commits
- name: gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# fails the job (non-zero exit) if any secret is found ->
# the PR cannot be merged.Detection is only useful with a rehearsed response. The instant a real secret is found in history, the credential is compromised — treat it as burned. The fix is to rotate (revoke and reissue) at the source, not to rewrite git history and hope. Managing secrets properly (so they are injected, never committed) is covered in depth by the Vault course.
Rotate first required — A secret that reached any shared history must be revoked and reissued. Removing the commit does not un-leak it.Incident runbook — "secret found in repo":
1. ROTATE the credential at the provider NOW (revoke + reissue).
The old value is dead; nothing else matters until this is done.
2. Check logs for use of the leaked credential (was it exploited?).
3. Replace with an injected secret (CI secret store / Vault),
never a committed value.
4. Optionally scrub history (git filter-repo) — but only AFTER
rotation; scrubbing without rotating is false comfort.
# proper secret handling is a whole topic -> see the Vault course.