Meta Description: Your .gitignore isn’t enough. Here’s why .env files still leak on GitHub, how attackers find them in minutes, and how to actually lock this down.
I still remember the Slack message that ruined my Tuesday morning: “hey, is this your AWS key in the repo?” followed by a link to a public GitHub mirror of a project I thought was private. I had a .gitignore file. I had .env listed in it, right there on line one. And it leaked anyway.
If you’re reading this because you just got a similar message, first — breathe, I’ll walk you through exactly what to do in the next ten minutes. If you’re reading this because you want to make sure it never happens to you, even better, because most of the fixes people apply after a leak are things I wish I’d done before mine.
Here’s the uncomfortable truth: .gitignore is one of the most misunderstood files in a developer’s toolkit. People treat it like a security control. It isn’t one. It’s a convenience feature that tells Git which files to stop asking about when you run git status. That’s it. It has no idea what happened before it existed, and it has zero authority over anyone else’s local copy of your repo.
How .env Files Actually Leak (It’s Rarely What You Think)
In my experience, teams assume a leak means someone forgot to add .env to .gitignore. That does happen, but it’s actually the least common cause I’ve seen in real incidents. Here are the ones that catch people off guard.
1. The File Was Committed Before .gitignore Existed
This is the classic one. You start a project, write some code, commit it, and then remember to add a .gitignore. Adding .env to .gitignore at that point does nothing to the version of .env that’s already sitting in your commit history. Git doesn’t retroactively forget files — it only ignores files it isn’t already tracking.
You can test this yourself right now:
git log --all --full-history -- .env
If that command returns any commits, your .env file (or a file with that name) has been part of your repository’s history at some point — regardless of what your .gitignore says today.
2. Someone Force-Added It
.gitignore can be overridden with a single flag:
git add -f .env
I’ve seen developers do this without realizing what -f means, usually while debugging why a file “won’t show up” in git status and reaching for the first Stack Overflow answer that makes the warning go away.
3. It’s Sitting in a Fork or an Old Branch
Deleting .env from your main branch and adding it to .gitignore doesn’t remove it from:
- Old branches that still contain the commit
- Forks other developers created before the cleanup
- Pull requests that were opened and never merged
- Tags that point to old commits
GitHub’s search and any automated scanner can — and does — crawl all of these.
4. CI/CD Logs Print It
This one gets overlooked constantly. A docker build or npm run build step that echoes environment variables for “debugging” will print your secrets straight into build logs. If those logs are visible to anyone with repo access (or the repo is public), your .env never had to be committed at all — it leaked through the back door.
5. .env.example Wasn’t Actually an Example
I’ve reviewed repos where .env.example contained real production credentials because someone copied .env to .env.example “just to show the format” and forgot to strip the values before committing.
Why GitHub Push Protection Doesn’t Catch Everything
GitHub has secret scanning and push protection, and I recommend enabling both — but don’t treat them as a safety net that makes this problem disappear.
| Protection | What It Catches | What It Misses |
|---|---|---|
| Secret Scanning | Known secret patterns (AWS keys, Stripe keys, etc.) after they’re pushed | Custom/internal secret formats, secrets in private repos on lower GitHub tiers, already-leaked history before scanning was enabled |
| Push Protection | Known secret patterns before the push completes | Secrets it doesn’t recognize the format of, secrets pushed via force-push bypass, secrets in binary files or logs |
| .gitignore | Untracked files with a matching filename | Anything already tracked, force-added files, renamed copies |
Push protection is genuinely useful — it stopped one of my own near-misses last year when I fat-fingered a commit that included a Stripe test key. But it’s pattern-matching, not comprehension. A custom internal token or a randomly generated secret in a format GitHub doesn’t recognize will sail right through.
The Immediate Fix: What to Do If You’ve Already Leaked a Secret
If you’re here because it already happened, do these in order. Don’t skip to step 3 before doing step 1 — I’ve made that mistake and it costs you real money.
Step 1: Rotate the Secret Immediately
Before you touch Git history, before you do anything else — go to the provider (AWS, Stripe, a database host, whatever issued the credential) and revoke or rotate it. A secret that’s been pushed to a public repo, even for thirty seconds, should be treated as compromised permanently. Bots scan public GitHub pushes in near real-time looking for exactly this.
# Example: revoke an AWS access key
aws iam update-access-key --access-key-id AKIA... --status Inactive --user-name your-user
aws iam create-access-key --user-name your-user
I learned this the hard way: removing the file from Git history first and rotating the key second means there’s a window where a scraped copy of your history (cached, forked, or mirrored) still has a live, working credential.
Step 2: Remove It From Git History
Once the secret is dead, clean the history so it stops showing up in scans and search results. Two solid tools for this:
Option A — git filter-repo (recommended, actively maintained):
pip install git-filter-repo
git filter-repo --path .env --invert-paths
Option B — BFG Repo-Cleaner (simpler for large repos):
bfg --delete-files .env
git reflog expire --expire=now --all
git gc --prune=now --aggressive
Both rewrite history, which means every collaborator needs to re-clone or hard-reset their local copy afterward — force-pushing a rewritten history without warning your team is a great way to spend the rest of your week untangling merge conflicts.
Step 3: Force-Push the Cleaned History
git push origin --force --all
git push origin --force --tags
Step 4: Tell GitHub Support About Cached Views (Public Repos Only)
If the repo was public, GitHub may have cached copies of old commits accessible via direct SHA even after a force-push. For sensitive leaks, contacting GitHub support to purge cached views is worth the extra step.
The Real Fix: Preventing It From Happening Again
Cleaning up after a leak is damage control. What actually matters is making the leak structurally impossible going forward. Here’s what I run on every project now.
Use a Pre-Commit Hook to Block Secrets Before They’re Committed
I use gitleaks as a pre-commit hook, and it’s caught more near-misses for me than I’d like to admit.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
This scans every commit before it’s created, not after it’s pushed — which is a meaningfully different guarantee than .gitignore or push protection give you.
Never Store Real Secrets in .env.example
Keep .env.example in version control with placeholder values only:
# .env.example — safe to commit
DATABASE_URL=postgres://user:password@localhost:5432/dbname
STRIPE_SECRET_KEY=sk_test_your_key_here
AWS_ACCESS_KEY_ID=your_access_key_here
Use a Secrets Manager for Anything Production-Facing
.env files are fine for local development. They’re a poor fit for production. In my experience, the moment a secret needs to exist in more than one environment, it belongs in a proper secrets manager:
| Tool | Best For | Notes |
|---|---|---|
| AWS Secrets Manager | AWS-hosted apps | Native IAM integration, automatic rotation |
| HashiCorp Vault | Multi-cloud, self-hosted | Steeper learning curve, very flexible |
| Doppler | Small-to-mid teams | Fast setup, good CLI/CI integration |
| GitHub Actions Secrets | CI/CD pipelines | Built-in, no extra infrastructure |
Set Repository-Level Rules
For any repo with real infrastructure behind it, I enable:
- Secret scanning + push protection (Settings → Code security)
- Required pull request reviews before merge to
main - Branch protection so force-pushes to
mainrequire admin override
Audit What’s Already Tracked
Run this periodically, especially on older repos you’ve inherited:
git ls-files | grep -E '\.env$|\.env\.'
Anything that returns here is currently tracked by Git — meaning it’s in your history whether or not it’s in your working directory right now.
Common Mistakes I See Teams Make
- Adding
.envto.gitignoreand assuming the problem is solved — without checking whether it was already tracked. - Rotating the secret but skipping history cleanup — the old key is dead, but a scraped copy of your commit history still tells attackers your naming conventions, your infrastructure, and where to look next.
- Force-pushing cleaned history without notifying the team — everyone else’s local clone still has the old history and will happily merge it right back in.
- Treating
.env.exampleas a backup instead of a template — copy-pasting real values “temporarily” and forgetting to strip them. - Printing environment variables in CI logs for debugging — and leaving that debug line in permanently.
Security Checklist
- [ ] Confirm whether
.envis currently tracked:git ls-files | grep .env - [ ] Check full history for past commits:
git log --all --full-history -- .env - [ ] Rotate any credential that has ever appeared in a commit, log, or fork
- [ ] Clean history with
git filter-repoor BFG if the file was ever tracked - [ ] Force-push cleaned history and notify all collaborators to re-clone
- [ ] Install a pre-commit secret scanner (gitleaks or similar)
- [ ] Strip real values from
.env.example - [ ] Enable GitHub secret scanning and push protection
- [ ] Move production secrets to a dedicated secrets manager
- [ ] Check CI/CD logs for accidental
echo $SECRETdebug statements
FAQ
Does deleting .env and adding it to .gitignore remove it from GitHub? No. Deleting the file and updating .gitignore only affects future commits. The file remains fully accessible in every previous commit that included it, unless you rewrite Git history with a tool like git filter-repo or BFG.
Is a leaked secret in a private repository still a risk? Yes. Private doesn’t mean permanently private — collaborators can leave, repos can accidentally be made public, forks can be created, and access tokens with repo scope can be compromised. Rotate any credential that’s touched version control, private or not.
Can I just make the repository private instead of rotating the key? No. If the key was ever pushed while the repo was public, or if it was ever visible to anyone outside your trust boundary, treat it as compromised regardless of the repo’s current visibility.
Will GitHub’s secret scanning catch every type of leaked credential? No. It matches known patterns from major providers. Custom internal tokens, randomly generated application secrets, or unusual formats often won’t be flagged automatically.
Do I need to rewrite Git history if the secret is already rotated? Rotating the secret removes the immediate danger, but the old value still sits in your history, readable by anyone with repo access, and it can leak information about your naming conventions and infrastructure. I clean history regardless, once the immediate fire is out.
What’s the safest way to manage environment variables in production? Use a dedicated secrets manager (AWS Secrets Manager, Vault, Doppler) rather than a .env file. Reserve .env files for local development only, and keep them out of version control entirely.
Final Thoughts
.gitignore protects you from committing a file tomorrow. It does nothing about the file you committed six months ago, the fork a former contractor still has, or the CI log printing your secrets in plain text every night. Treat every credential that’s ever touched your repository
If you found this useful, I’ve got more practical, no-fluff guides on securing your dev workflow over on SpiritCode — including deep dives on Docker security and dependency supply-chain attacks. Worth a look before your next production deploy.

