When a secret is still in git history, the cause is almost always one of five things: the .env file was deleted in a later commit instead of being removed from history, the file was committed before a .gitignore rule existed, a key was hardcoded in source or config, a rewrite cleaned the main branch but not every ref, or an old clone or fork pushed the history back. The fix order is fixed too: rotate or revoke every exposed key first, then rewrite history with git filter-repo (or BFG), force push all refs, have every collaborator re-clone, ask GitHub Support to clear cached views, and add .gitignore rules, a pre-commit secret scan and GitHub push protection so it does not happen again.
Git history is the full chain of commits stored in a repository. Every commit is a snapshot, so a deleted file stays readable in older commits. A secret here means any credential that grants access: an API key, a database password, a payment provider secret key, a service role key, an OAuth client secret or a private key. Rewriting history means generating a new chain of commits without the file, which changes every commit hash from the first affected commit forward.
We ran into this in a security audit of our own repositories. Several private repos had .env files with live keys still sitting in history, even though later commits had deleted the files. Separately, a public deploy URL was serving build files that contained keys. Neither showed up in the current code.
The five causes we find most often.
- Deleted, not purged: a later commit removed the .env file, so the current tree is clean but every earlier commit still holds the file and anyone can check it out or read it with git show
- Committed before .gitignore: the file was added in the first commits of the project, and the ignore rule added later only stops new changes from being tracked, not the copy already in history
- Hardcoded in source: a key pasted into a config file, a script or a test fixture, which no .env rule will ever catch
- Partial rewrite: history was cleaned on the main branch but tags, other branches or pull request refs still point at the old commits
- Recontamination: someone with a clone from before the rewrite ran git pull and then git push, which brings the old commits, and the secret, back
Does deleting a file remove it from git history?
No. Deleting a file in a new commit removes it from the current tree only; every earlier commit still contains it. Git stores snapshots, and the delete is just one more snapshot. Anyone with read access, any existing clone and any fork can still read the old version.
You can confirm it on any repo in seconds. Run git log --all --full-history -- .env to list every commit that touched a file named .env on any ref. If that prints commits, the file is in history. To find a key that was hardcoded somewhere else, git log --all -p -S 'unique-part-of-key' searches every diff for that string. Run these locally and never paste the output anywhere, since it contains the secret.
What do you do first when a secret is in git history?
Rotate or revoke the secret before you touch history. GitHub's guide to removing sensitive data says that when the data is a password, token or credential, the first step is to revoke and/or rotate it. Rewriting history cleans the server copy, but it cannot reach clones, forks or anything already copied. A revoked key is worthless, and that is the only step that actually ends the exposure.
Rotate in the issuing system, not just in your .env file. Create the new key, update it in the host's environment settings for every environment that uses it, redeploy, confirm the app works, then revoke the old key. For integration tokens, such as the system user token behind a Meta Conversions API setup or a developer token used by Google Ads API scripts, check every place that reads the old value before you revoke it, or you will trade a leak for an outage.
How to find every exposed secret.
Do not trust the current code. Check history, then the live builds, then the platform alerts.
History, per repository. Run the git log commands above for .env, .env.local, .env.production and any other secrets file name your stack uses. Then search history for the prefixes your providers use in their key formats.
Build output, per deploy. Open the deployed site, open developer tools, go to the Sources tab and search the loaded JavaScript for the same prefixes. In Next.js, any variable prefixed with NEXT_PUBLIC_ is inlined into the JavaScript bundle at build time and sent to the browser, so a secret given that prefix is public by design. Check preview and older deployment URLs too.
GitHub secret scanning. When enabled, secret scanning scans the entire git history on all branches for supported secret types and lists alerts in the repository's security area. It runs automatically for free on public repositories. Private repositories need GitHub Secret Protection on a Team or Enterprise Cloud plan.
Diagnose and fix in order.
This is the order we run. Do not skip ahead to the rewrite.
git-filter-repo --sensitive-data-removal --invert-paths --path .env. To replace a hardcoded string everywhere: put the literal value on a line in a file outside the repo and run git-filter-repo --sensitive-data-removal --replace-text ../secrets.txt. Matches become ***REMOVED*** unless you set another replacement with ==>. Save the First Changed Commit(s) line.git push --force --mirror origin. GitHub notes this will fail to push refs starting with refs/pull/, which is expected. If other refs fail, temporarily lift branch protection, push, and restore it.Which tool should you use to rewrite git history?
GitHub recommends git filter-repo, and its --sensitive-data-removal flag requires git filter-repo version 2.47 or later. That flag fetches all refs so data outside branches and tags is cleaned too, and reports the first changed commits and any orphaned Git LFS objects. git filter-repo also refuses to run outside a fresh clone unless you pass --force, a safety check worth respecting.
BFG Repo-Cleaner still works and is fast for deleting files by name or replacing text across history. The gotcha: by default BFG does not modify the contents of the latest commit on your HEAD branch, so you must remove the secret from the current commit first. After BFG, its docs have you run git reflog expire --expire=now --all && git gc --prune=now --aggressive before pushing.
| Symptom | Likely cause | Fix |
|---|---|---|
| File is gone from the repo but git log --all shows it | Deleted in a later commit, never purged | Rotate keys, then git filter-repo --invert-paths --path |
| Key appears in a config or script, not a .env file | Hardcoded secret | Rotate, move to host env vars, filter-repo --replace-text |
| Key visible in browser Sources on the live site | Server key given a public build prefix | Rotate, remove the prefix, keep the key server-side, redeploy |
| Secret back in history a week after the rewrite | Old clone pulled and pushed | Rotate again, re-run rewrite, require fresh clones |
| Main branch clean but secret found on a tag or old branch | Rewrite did not cover every ref | Rewrite in a fresh clone, push with --force --mirror |
| Closed pull request diff still shows the secret | Cached views and pull request refs on GitHub | Contact GitHub Support with First Changed Commit(s) |
| Push rejected with a secret detected message | Push protection caught a supported secret | Remove it from the commits, rotate if it was real, push again |
| Old deploy URL still loads with the old key in its bundle | Preview builds keep serving their original output | Rotation covers it; remove or protect the old deployment |
Does GitHub keep a copy after you force push?
It can. GitHub's guidance says data can remain in cached views, pull request references and forks after a force push. If the commit that introduced the secret exists in a fork, it stays accessible there, and you need to coordinate with the fork owners. GitHub Support can remove cached views and dereference affected pull requests once you send the details from the rewrite. Rewriting also changes every commit hash from the first affected commit forward, breaks diffs on closed pull requests and removes signatures from rewritten commits, so warn the team first.
Does GitHub block secrets before they are pushed?
Push protection blocks a push that contains a supported secret and explains why, before it reaches the repository. As of October 2026, push protection for users is on by default for pushes to public repositories. Repository push protection is off by default, requires GitHub Secret Protection, and is enabled under Settings, then the Security and quality section, then Advanced Security, where you enable Secret Protection and then Push protection. Anyone with write access can bypass a block by choosing a reason, so review bypasses.
Push protection only knows the patterns GitHub supports. A pre-commit hook running a secret scanner catches mistakes before a commit exists. GitHub's guide also suggests avoiding git add . and git commit -a in favor of reviewing what you stage.
What is normal and what is a red flag?
Some public values are meant to be public; server secrets never are. A publishable key, a site key or an analytics measurement ID is designed to ship to the browser, and finding one in a bundle is normal. A secret key, a service role key, a database URL with a password, a private key or an admin API token in a bundle or in history is a red flag every time. The provider's docs for each key type say which are safe for client code.
Normal after a rewrite: every commit hash changes, open pull requests need to be recreated or rebased, and teammates get errors until they re-clone. Red flags: the history search still finds the file, a secret scanning alert reopens, or a teammate merged the old history back in. The same audit discipline we use for a conversion tracking audit checklist applies here: check what is actually served and stored, not what the settings screen says. If you are moving hosts at the same time, fold this into your website migration checklist. Protecting old previews has side effects of its own, covered in password protected previews with broken images.
How do I remove a .env file from git history?
First rotate every key in the file. Then, in a fresh clone, run git-filter-repo --sensitive-data-removal --invert-paths --path .env, push with git push --force --mirror origin, have collaborators re-clone, and contact GitHub Support with the First Changed Commit(s) so cached views and pull request references are cleared. Add .env to .gitignore afterwards.
Is a secret in a private repository's history still a leak?
Treat it as one. Everyone who has ever had access, every clone, every CI cache and every fork holds a copy, and access lists change over time. Rotate the key, then clean history so the repository stops carrying it forward.
Should I use git filter-repo or BFG?
GitHub recommends git filter-repo, version 2.47 or later for the --sensitive-data-removal flag. BFG is a valid alternative, but by default it leaves the latest commit on HEAD untouched, so remove the secret from the current code first and run git gc afterwards.
Why is my API key visible in the browser?
Your framework inlined it into the client bundle at build time. In Next.js, any variable prefixed NEXT_PUBLIC_ is replaced with its literal value in JavaScript sent to the browser. Rotate the key, remove the prefix, move the call to server code and redeploy.