Prevent Secret Leaks
Set up a layered defense against secret leaks: pre-commit hooks, CI scanning, gitignore validation, and automated migration to a secret manager.
Prerequisites
This is the guide I wish had existed before my incident. It walks through the exact layered setup I now run on every project — multiple independent checks so that if one layer fails, the next catches it.
Defense in depth
No single tool is enough. Pre-commit hooks can be bypassed with --no-verify. CI can be skipped on certain branches. This guide sets up multiple independent layers so you'd have to actively defeat each one.
The threat model
You're protecting against:
- ›Accidental commits — you typed
git add .when you meantgit add src/ - ›Gitignore drift — you added a new service directory but forgot to cover its
.env - ›Dependency confusion — a new developer clones the repo and commits a real
.env - ›Rushed hotfixes — 2 AM, production is down, you skip your usual process
The setup below catches all four.
Layer 1 — gitignore (foundational)
Before anything else, make sure every .env in your project is covered:
evnx doctor[DOCTOR] Running health check...
[WARNING] packages/api/.env is tracked by git
[WARNING] .gitignore at root does not cover packages/api/.env
[OK] .gitignore covers .env at root
Fix automatically:
evnx doctor --fix
# [FIX] Added packages/api/.env to .gitignore
# [FIX] Created packages/api/.env.example from packages/api/.envMonorepo gotcha
The most common failure mode is a monorepo where the root .gitignore covers /.env but not packages/*/env or apps/*/.env. evnx doctor recursively checks all subdirectories.
Verify nothing is tracked that shouldn't be:
git ls-files | grep '\.env$'
# If this returns any real .env files, they're tracked — remove them:
git rm --cached packages/api/.envLayer 2 — pre-commit hook
The pre-commit hook is your primary defense. It runs before every git commit and blocks commits that contain secrets.
Setup with the pre-commit framework (recommended for teams)
# Install pre-commit
pip install pre-commit
# or: brew install pre-commitCreate .pre-commit-config.yaml in your repo root:
repos:
- repo: local
hooks:
# Layer 2a: evnx secret scan
- id: evnx-scan
name: evnx — scan for secrets
entry: evnx scan --exit-code --severity high
language: system
files: '\.env'
pass_filenames: false
stages: [commit]
# Layer 2b: evnx doctor (gitignore check)
- id: evnx-doctor
name: evnx — environment health check
entry: evnx doctor --exit-code
language: system
pass_filenames: false
stages: [commit]Install:
pre-commit install
# pre-commit installed at .git/hooks/pre-commitTest it works:
# Create a file with a fake secret
echo "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" > .env.test
git add .env.test
git commit -m "test: should be blocked"
# evnx — scan for secrets.....................................FAILED
# [ERROR] AWS_SECRET_ACCESS_KEY — high entropy string detected
# Commit blocked.Setup for individual developers (simpler)
If you don't want to use the pre-commit framework:
cat > .git/hooks/pre-commit << 'HOOK'
#!/bin/bash
set -e
echo "evnx: scanning for secrets..."
evnx scan --exit-code --severity high
echo "evnx: checking environment health..."
evnx doctor --exit-code
echo "evnx: all checks passed"
HOOK
chmod +x .git/hooks/pre-commitTeam distribution
The .git/hooks/ directory is not committed to the repo — each developer needs to run the setup individually. The pre-commit framework approach is better for teams because .pre-commit-config.yaml is committed, and pre-commit install is a one-time setup.
Layer 3 — CI/CD gate
Pre-commit hooks can be bypassed with git commit --no-verify. CI cannot be bypassed.
Set up a GitHub Actions workflow that runs on every PR:
# .github/workflows/evnx-security.yml
name: Secret scan
on:
pull_request:
push:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Install evnx
run: curl -fsSL https://dotenv.space/install.sh | bash
- name: Scan for secrets
run: evnx scan --format github --exit-code
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: evnx.sarif
continue-on-error: trueSet this workflow as a required status check in your branch protection rules:
Settings → Branches → Branch protection rules → Require status checks.
Now git push --no-verify still gets caught at the PR level.
Layer 4 — .env.example discipline
Keep .env.example in sync with .env. When a developer clones the repo and copies .env.example to .env, they should get a complete list of required variables (with no real values).
# Generate .env.example from .env
evnx sync --generate-example
# Check if they've drifted
evnx diff .env .env.exampleAdd this to the pre-commit config:
- id: evnx-sync-check
name: evnx — check .env.example is in sync
entry: evnx diff --exit-code .env .env.example
language: system
files: '\.env'
pass_filenames: falseLayer 5 — migrate to a secret manager (optional but ideal)
The ultimate fix: stop storing secrets in .env files at all. evnx migrate pushes your secrets to a cloud secret manager and replaces the values with references:
# Push to AWS Secrets Manager
evnx migrate --to aws-secrets-manager --prefix /myapp/prod
# Push to Doppler
evnx migrate --to doppler --project myapp --config prod
# Push to GitHub Actions secrets
evnx migrate --to github-actions --repo myorg/myappAfter migration, your .env contains only references:
DATABASE_URL=secret:aws:///myapp/prod/DATABASE_URL
STRIPE_SECRET_KEY=secret:aws:///myapp/prod/STRIPE_SECRET_KEYYour application reads the actual values at runtime from the secret manager — the .env file no longer contains anything sensitive.
Verification checklist
After completing this setup, verify:
# 1. No .env files tracked by git
git ls-files | grep '\.env$' # should return nothing
# 2. Doctor passes clean
evnx doctor
# 3. Pre-commit hook is installed
ls .git/hooks/pre-commit # should exist and be executable
# 4. Test the hook actually blocks
echo "FAKE_KEY=AKIAIOSFODNN7EXAMPLE" >> .env
git add .env && git commit -m "test" # should be blocked
# 5. CI workflow exists
ls .github/workflows/evnx-security.ymlCommon failure modes
"I bypassed the hook with --no-verify" — That's why CI exists (Layer 3). The workflow catches what the hook misses.
"My teammate didn't install the hook" — Use the pre-commit framework and document it in your CONTRIBUTING.md. Make CI the mandatory gate.
"I have 200 existing issues in a legacy repo" — Use --exit-zero mode in CI first to audit without blocking. Fix findings progressively, then switch to --exit-code.
"The hook is too slow and developers disable it" — evnx scan runs in ~180ms. If it's slower, check --recursive isn't scanning node_modules. Use .evnx.toml to exclude large directories.
Related
- ›GitHub Actions integration — full CI workflow setup
- ›evnx doctor reference — all health checks
- ›evnx migrate — moving secrets to a manager