Every developer who has done this knows the feeling of the push completing. The important thing is that the first move is rotation, not git.
Why .env keeps getting committed
The .env convention arrived with dotenv around 2012 and won because it is the least friction possible: a file of KEY=value lines, loaded into process.env at start-up, ignored by git. The last part is a convention, not a property of the file, and that is the whole problem.
Four ways it goes wrong, and none of them require carelessness:
- The file was tracked before the rule existed. This is the big one.
.gitignoreonly affects untracked files, so adding.envto it after the first commit changes nothing at all. Git keeps reporting the file as modified and keeps committing it, which is exactly the confusion we untangle in .gitignore not working. The fix isgit rm --cached .env, and the check isgit check-ignore -v .envor a paste into our gitignore tester, which shows which rule matched and which did not. - A variant slipped past the pattern. The rule says
.env, the file is.env.production,.env.local.bakorenv.txt. Ignore.env*and re-include the example with!.env.example. git add -Ain a hurry, usually during a deploy at the end of a long day.- The secret was never in a
.envat all. A connection string in a test fixture, a token in a Jupyter notebook output cell, a password in a docker-compose file, an API key pasted into a code comment to try something quickly.
Rotate first, everything else second
The moment a credential reaches a remote, treat it as public. Not "probably fine", not "the repo is private and we caught it in two minutes". Public.
Public repositories are scanned continuously by bots that watch the GitHub events firehose, and cloud credentials are the prize. The window between push and first use is measured in minutes, and the outcome is usually a fleet of expensive instances mining cryptocurrency on your account. Private repositories are lower risk, not no risk: every clone, every CI cache, every laptop backup now contains the value.
So the order is:
- Generate replacement credentials.
- Deploy them.
- Revoke the old ones and confirm the revocation took effect.
- Check the logs of every affected service for use you cannot account for, between the push and the revocation.
- Only now, clean the repository.
Step four is the one that gets skipped, and it is the one that turns a leak into an incident report. For a webhook signing secret the rotation itself needs a plan, because the sender and receiver have to agree during the changeover, which is the two-key overlap described in verifying webhook signatures. For anything you have to invent a new value for, generate it from a CSPRNG rather than typing something: our password generator produces one entirely in your browser.
One genuinely good piece of news: GitHub's secret scanning partner programme means many providers are told directly. Push an AWS key, a Stripe live key or a Slack token to a public repository and the provider is notified automatically, often revoking it before you have finished reading the alert email. Do not rely on it, but do not be surprised when a key is dead before you got to it.
The history rewrite that does not save you
Now the repository. Understand first what this step does and does not achieve: it stops the secret being found by someone browsing the repo. It does not unleak anything.
The standard tool is git filter-repo, which GitHub recommends over the older options:
git filter-repo --invert-paths --path .env
BFG Repo-Cleaner does the same job and is faster on very large histories. git filter-branch still exists, warns you that it is slow and error-prone, and should be left alone. All of them rewrite every commit after the one they touched, so every SHA changes, every open pull request needs rebasing, and everyone with a clone has to re-clone. Announce it before you force-push, not after.
Then the parts a rewrite cannot reach:
- Unreachable commits on GitHub. The old commit object is still there and still served to anyone with the SHA. Those SHAs appear in old pull requests, CI logs and Slack messages.
- Forks. A fork is an independent copy. Your rewrite has no effect on it, and you cannot rewrite someone else's fork.
- Cached views. GitHub Support can purge these on request, which is worth doing and is not instant.
- Every local clone, including the CI cache and the developer on holiday.
This is why rotation is step one and history is step five. If the credential has been replaced and revoked, an old commit containing it is embarrassing rather than dangerous.
The other places a secret ends up
Git is the obvious one. These are the ones people find weeks later.
Docker layers. A COPY . . without a .dockerignore pulls .env into the image. Deleting it in a later RUN removes it from the final filesystem and leaves it in the earlier layer, fully readable to anyone who can pull the image. The same applies to ARG values, which are recorded in the image history. Use .dockerignore, and BuildKit's --mount=type=secret when a build step genuinely needs a credential.
Build output and source maps. Anything the bundler inlines ships to the browser, and a published source map carries the original file it came from, comments included. If you have shipped maps for a build that read a secret at build time, the secret is in them, which is one of the reasons the hidden source map pattern exists.
CI logs. A set -x in a shell step prints every expanded variable. Most CI systems mask registered secrets in output, and they can only mask values they were told about, so a variable read out of a checked-in file appears in plain text.
HAR files and screenshots. A HAR captures every request header, including Authorization, and gets attached to support tickets routinely. Sanitise before sharing.
Backups and dotfile repositories. The public dotfiles repo with a working .aws/credentials in it is a genre.
The prefix that publishes your key
A specific, extremely common mistake that is not really a leak at all: it is a documented feature, used incorrectly.
Client-side frameworks need a way to expose configuration to browser code, and they use a prefix as the marker. NEXT_PUBLIC_ in Next.js, VITE_ in Vite, PUBLIC_ in Astro and SvelteKit, REACT_APP_ in the old Create React App. Any variable with the prefix is inlined into the JavaScript bundle at build time. Not fetched at runtime, not proxied: written into a file that every visitor downloads.
It goes wrong when a service issues two keys whose names differ by one word. Supabase's anon key is designed to be public and safe behind row level security; its service role key bypasses every policy. Stripe's publishable key is meant for the browser; the secret key can move money. Put the wrong one behind the prefix and you have published it, with no commit to revert.
So: never put a value behind a public prefix without saying out loud what happens if a stranger has it, and grep your built bundle for the first eight characters of each secret before a release. It takes seconds and catches this class entirely.
Stopping it at the commit
Three layers, cheap enough that there is no reason to run fewer than all three.
- Local: a pre-commit hook running
gitleaksortrufflehog. Catches the accident at the moment it happens, when the fix is deleting a line. - CI: the same scanner as a pipeline step, because
--no-verifyexists and gets used. - Platform: GitHub's push protection rejects a push containing a recognised credential pattern. It is on for public repositories and available for private ones with Advanced Security, and it is the only layer that works when someone commits from a machine you do not control.
Around them, the small structural things. Put .env* with a !.env.example negation in a global gitignore so no individual project has to remember it. Commit a complete .env.example with empty values, since a new clone that documents its own requirements is how you stop people copying working files around. And run git check-ignore -v on a new ignore rule instead of assuming, because a rule that does not match looks exactly like a rule that does until the day it matters.
Where secrets should live instead
A .env file is fine for local development. For anything deployed, the file is a compromise, and the alternatives are no longer exotic.
Platform-level environment variables (Vercel, Fly, Railway, Kubernetes secrets) keep values out of the image and let you rotate without a rebuild. Dedicated stores such as Vault, AWS Secrets Manager or Doppler add access control, audit logs and rotation on a schedule, which is what turns "we should rotate that" into something that actually happens. Short-lived credentials are better still: an OIDC trust between your CI provider and your cloud account removes long-lived deploy keys from the picture entirely, and a credential that expires in an hour is a much smaller incident.
Whatever you choose, assume the file will be committed once. Design for rotation being routine, keep an inventory of which credentials exist and where, and the day someone runs git add -A at the wrong moment becomes an hour of work rather than a week.
After a leaked secret
I committed a .env file. What do I do first?
Rotate every credential in it, before anything else. Rewriting history, force-pushing and apologising in the channel all take longer than a bot takes to find an AWS key in a public repository, and none of them invalidate a key that is already copied. Issue new credentials, deploy them, revoke the old ones, and only then deal with the repository. If the repo was private and you are confident about who had access, the urgency drops but the order does not change.
Does deleting the commit remove the secret from GitHub?
No. A commit that is no longer reachable from any branch still exists on GitHub and is served if anyone knows or guesses its SHA, and forks made before the deletion keep their own copy that your rewrite cannot touch. Pull request views can also hold cached diffs. GitHub Support can purge cached views on request, but that is cleanup after the fact: the credential must be treated as public from the moment it was pushed.
How do I remove a file from the whole git history?
Use git filter-repo, which is what GitHub now recommends: git filter-repo --invert-paths --path .env, then force-push every branch and tag. BFG Repo-Cleaner is the older alternative and is faster on very large repositories. git filter-branch still exists but is slow, easy to get wrong and carries a deprecation warning from git itself. All three rewrite every commit SHA after the touched commit, so coordinate with everyone holding a clone before you push.
Should .env.example be committed to the repository?
Yes, and it is the single habit that prevents the most accidents. Commit a .env.example with every key present and every value empty or obviously fake, so a new clone documents what is needed without carrying anything real. Keep it in sync with the actual keys, since the moment it drifts people start copying values from a colleague’s working file instead, which is how secrets end up in chat.
Are NEXT_PUBLIC_ or VITE_ environment variables secret?
No, they are the opposite: those prefixes exist to tell the bundler to inline the value into the client bundle. NEXT_PUBLIC_, VITE_ and the older REACT_APP_ variables are compiled into JavaScript that every visitor downloads, so anything behind them is published. This bites hardest with service keys that have a public and a secret variant, such as Supabase or Stripe, where the names differ by one word and only one of them is safe to expose.
Is it safe to keep a .env file on the production server?
It is acceptable and common, provided the file is owned by the service user with 0600 permissions and lives outside the web root. What people underestimate is how visible the resulting environment is: docker inspect prints it, /proc/PID/environ exposes it to anything running as that user, and a crash reporter that dumps the environment will happily ship it to a third party. A managed secret store injected at start-up is better; a well-permissioned .env is the acceptable version of good enough.
Why is my secret still in the Docker image after I deleted it?
Because image layers are immutable. If one layer copied the file in and a later RUN deleted it, the file is gone from the final filesystem and still fully readable in the earlier layer, which anyone with the image can extract. The same applies to build arguments passed with ARG, which are recorded in the image history. Keep the file out of the build context with .dockerignore, and use BuildKit secret mounts when a build genuinely needs a credential.
How do I stop secrets being committed in the first place?
Three layers, each cheap. A pre-commit hook running gitleaks or trufflehog catches most of it locally; a CI job running the same scanner catches what people commit with --no-verify; and GitHub’s push protection blocks a recognised credential pattern at push time for public repositories and for private ones with Advanced Security. Add .env to your global gitignore so no project has to remember, and keep the repository-level entry as well.