The fix in one command: install a pre-commit hook that scans staged changes before they're allowed into a commit.
# One-time setup, any repo
brew install gitleaks # or: go install github.com/gitleaks/gitleaks/v8@latest
# .git/hooks/pre-commit (make it executable: chmod +x)
#!/bin/sh
gitleaks protect --staged --redact -vThat single hook stops the most common leak path — git add .env run by accident, or a real secret pasted into a tracked file instead of a gitignored one. Everything below is the rest of the workflow: why leaks happen despite .gitignore, how to layer prevention so one missed step doesn't mean a leaked key, and what to automate in CI so prevention doesn't depend on every teammate remembering the hook.
The core problem
.gitignore is necessary but not sufficient. It only stops new tracked files — it does nothing once a file is already tracked, and it does nothing about secrets pasted into files that were never meant to hold them. In practice, most .env leaks trace back to one of four habits:
- .gitignore added after the first commit. If
.envwas tracked before the ignore rule existed, the rule is silently a no-op —git statusshows the file as clean, not ignored, because git is already tracking it. - Real values copy-pasted for convenience. A teammate asks for the Stripe key, someone pastes the live
.envinto Slack or a test file "just for now," and it ends up committed in that test file instead. - Secrets baked into fixture or example files. A working secret gets used as a "realistic" test fixture and never swapped for a placeholder before the commit.
- No check between local commit and remote push. Without a hook or CI gate, the only thing preventing a leak is a human noticing during code review — which fails exactly when someone's rushing.
Step-by-step implementation
1. Confirm .gitignore is actually working
.gitignore only blocks files git isn't tracking yet. Check whether .env is already tracked before assuming the ignore rule protects you:
# If this prints anything, .env is tracked despite .gitignore
git ls-files | grep -E '^\.env($|\.)'
# Untrack it without deleting the local file
git rm --cached .envThen confirm your ignore rules actually match your file layout:
# .gitignore
.env
.env.local
.env.*.local
.env.development
.env.production
.env.test
!.env.example2. Install a pre-commit hook (gitleaks)
For a team, use husky so the hook installs automatically for every contributor on npm install, instead of relying on everyone setting up .git/hooks by hand:
npm install -D husky
npx husky init
# .husky/pre-commit
gitleaks protect --staged --redact -v3. Add secret scanning in CI
A local hook can be skipped with --no-verify or a teammate's outdated setup. CI is the backstop that runs regardless:
# .github/workflows/gitleaks.yml
name: gitleaks
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Also turn on GitHub's built-in push protection (Settings → Code security → Secret scanning) — it blocks known secret formats (Stripe, AWS, GitHub tokens, and ~200 others) at push time, before the commit even lands on the remote.
4. Stop generating real secrets locally in the first place
A large share of "accidentally committed" secrets are values a developer generated once, in a real environment, and then reused locally out of convenience. Generate throwaway, correctly-shaped values for local development instead — a random secret, a JWT secret, or a test API key — so a leaked local .env exposes nothing that touches production.
Security & best practices
- Rotate on suspicion, not on proof — by the time you've confirmed a leak, the exposure window already happened.
- Never disable a scanning hook to work around a false positive; add a scoped allowlist rule instead.
- Keep
.env.examplecurrent so nobody needs to copy a real.envto know what keys exist — generate it from your live file in one click. - Treat preview/staging deployments as public — scope only test credentials to them, never production keys.
- Redact env values in CI logs explicitly; don't assume your CI provider masks a custom key name automatically.
Prevention methods compared
| Method | Catches it before commit? | Catches it before push? | Setup time |
|---|---|---|---|
.gitignore alone | No new tracked files only | No | 1 min |
| Pre-commit hook (gitleaks) | Yes | Yes | 5 min |
| CI secret scanning | No | Yes (blocks merge) | 10 min |
| GitHub push protection | No | Yes (blocks push) | 1 min (toggle) |
| Manual browser check | Depends on discipline | Depends on discipline | 0 min, run anytime |
None of these are mutually exclusive — the hook stops most leaks locally, CI catches what the hook missed, and push protection is a free last line of defense with zero setup cost.
Quick manual check: before sharing or committing a
.env, run it through the browser-based leak checker — it matches 17+ known secret shapes (AWS, Stripe, GitHub, OpenAI, Slack, private key blocks, and more) entirely client-side. Nothing is uploaded; it's a two-second gut check before you hit enter ongit commit.
Troubleshooting & FAQ
I already committed a .env with real secrets — is this guide enough?
No — this guide is prevention, not cleanup. If a secret is already in git history, rotate it first (assume it's compromised the moment it's pushed, even to a private repo), then follow our guide to scrub .env from git history.
Will a pre-commit hook slow down every commit?
Gitleaks scans a typical diff in well under a second — it only checks staged changes, not your whole repo, on every commit. The exception is your first install, when you may want to run a one-time full-history scan (gitleaks detect --source .) separately from the hook.
Does GitHub's built-in secret scanning cover custom API keys?
Partially. GitHub's push protection recognizes known formats from ~200 providers (AWS, Stripe, GitHub tokens, etc.) automatically. Custom or internal secret formats need a custom pattern added in repo settings, or a tool like gitleaks that you configure with your own regex rules.
What if gitleaks flags something that isn't a real secret?
Add an inline allowlist comment (gitleaks:allow) on that exact line, or add a path/rule exception to .gitleaks.toml. Don't disable the hook entirely to work around one false positive — that's how real secrets slip through later.
If it's too late
If a real secret is already in a commit — pushed or not — this guide won't undo that. Rotate the secret first, then follow how to remove .env from git history to scrub it with git filter-repo or BFG. Prevention only pays off going forward; it doesn't retroactively protect a value that's already out.