security7 min read

How to Avoid Committing Secrets to .env Files (2026)

A prevention workflow, not a cleanup guide: pre-commit hooks, CI secret scanning, and the habits that stop a .env leak before git add ever runs.

Ali Niyaz·Published

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 -v

That 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 .env was tracked before the ignore rule existed, the rule is silently a no-op — git status shows 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 .env into 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 .env

Then confirm your ignore rules actually match your file layout:

# .gitignore
.env
.env.local
.env.*.local
.env.development
.env.production
.env.test
!.env.example

2. 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 -v

3. 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.example current so nobody needs to copy a real .env to 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

MethodCatches it before commit?Catches it before push?Setup time
.gitignore aloneNo new tracked files onlyNo1 min
Pre-commit hook (gitleaks)YesYes5 min
CI secret scanningNoYes (blocks merge)10 min
GitHub push protectionNoYes (blocks push)1 min (toggle)
Manual browser checkDepends on disciplineDepends on discipline0 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 on git 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.

Try these tools

Related guides