How Secrets End Up in Git History (and How to Get Them Out)
"I deleted that file already" is the most common - and most wrong - response to finding a secret in a repository. Git doesn't forget; it just stops showing you the old version by default.
Why Deleting the File Doesn't Delete the Secret
Every commit in a Git repository is a permanent, addressable snapshot. Deleting a file - or editing it to remove a secret - creates a new commit; every commit before it, including the one that introduced the secret, still exists in full and is retrievable with ordinary Git commands:
git log -p -- path/to/config.ts shows every historical version of that file, secret included, regardless of what the file looks like today.
How Secrets End Up Committed in the First Place
- A real API key pasted into a config file during local testing, committed "temporarily," and never removed before the next commit.
- A
.env.exampleor sample config file accidentally populated with real values copied from a working.env. - A secret embedded in a CI/CD pipeline YAML file instead of pulled from a secrets manager.
- Debug logging or a code comment left in that includes a live token, added during troubleshooting and forgotten.
The Moment You Find One: Rotate Before You Clean
Revoke and reissue the credential at its source - the payment provider dashboard, the cloud IAM console, the database - immediately, before doing anything else. Treat it as compromised the second it was pushed, regardless of whether you can confirm anyone actually accessed the repository in the meantime. Public repos are actively, continuously scraped by automated bots looking for exactly this pattern; private repos are not immune either, since access, forks, and exports can all happen without your knowledge.
Actually Removing It From History
Once the credential is rotated and no longer valid, purging it from history is a hygiene step, not an emergency one - but still worth doing. This requires rewriting history, not just committing a fix:
git filter-repo --path config.ts --invert-paths
This rewrites every commit that touched the file, removing it entirely from the project's history. It changes every commit hash downstream of the earliest affected commit, which means a force-push and, critically, every collaborator needs to re-clone or hard-reset - a rewritten history that some clones don't adopt reintroduces the old commits (and the secret) the next time someone pushes from a stale clone.
Preventing the Next One
- Move configuration to environment variables read at runtime, never hardcoded literals in source.
- Add a pre-commit secret scanner (gitleaks, detect-secrets) that blocks a commit containing a secret-shaped string before it's ever pushed.
- Use obviously-fake placeholder values in any example or template config committed to the repo - never a real value with the last few characters redacted, which is often still guessable.
- Wire a secrets scan into CI as a backstop for anything a local pre-commit hook misses.
Automate this in your next PR
Run a free repo scan and see outdated dependencies, CVEs, and code quality issues before they ship.
Run a free repo scan