Skip to content

Secrets in Source Control: How to Find and Remove Them

Sep 2, 2026 · Security

A secret committed to source control must be treated as compromised the moment it lands, because version control is designed to remember everything — deleting it in a later commit does not remove it from history. API keys, database passwords, OAuth client secrets, and signing keys end up in Git constantly: hard-coded in a config file, pasted into a test, or dropped into a .env that was never git-ignored. The dangerous property of Git is exactly what makes it useful: every version of every file is preserved. This article explains why that persistence matters, how to hunt for secrets with patterns you can test, and the correct order of operations to clean up — rotate first, then rewrite history.

Why is a committed secret so dangerous?

Because Git history is permanent by design, and a secret written to it is not erased by a later “remove key” commit. When you delete the line and commit, you have only changed the current snapshot. Every prior commit that contained the secret still contains it, and anyone with read access can check out that commit or run git log -p to see it. If the repository was ever pushed to a shared host, or cloned by a colleague or a CI runner, copies of that history exist beyond your control.

Several assumptions make this worse than people expect:

  • Private is not safe. Access lists change, repositories get forked, backups are taken, and a repo can be flipped to public by mistake.
  • Clones are frozen copies. Anyone who cloned before your fix keeps the full history, including the secret, on their disk.
  • Automation reads history too. CI systems, mirrors, and code-scanning bots may have already ingested the commit.

The practical consequence: once a real secret is in history, you cannot make it “un-leaked” by editing files. You can only invalidate the secret itself and then remove its traces so it is not leaked again to future readers.

How do I find secrets already in a repository?

Scan both the working tree and the full history with patterns aimed at how secrets are shaped. Many credentials have recognisable structure — a fixed prefix, a fixed length, or a characteristic character set — which makes them findable with regular expressions. To search the current files you can grep:

git grep -nE "(api[_-]?key|secret|token|password)"

# search across all of history, not just the checkout:
git log -p -S "AKIA" --all

The -S “pickaxe” is important: it finds commits where a string was added or removed anywhere in history, which is exactly where a since-deleted secret hides. Useful regex building blocks include:

Target Pattern idea Note
Assignment of a suspicious variable (?i)(api[_-]?key|secret|token|passwd|password)s*[:=]s*['"][^'"]+['"] Catches API_KEY = "..." style lines.
Long hex strings b[0-9a-fA-F]{32,}b Many keys and hashes are 32+ hex chars.
base64-looking blobs b[A-Za-z0-9+/]{40,}={0,2}b High false-positive rate; combine with context.
Prefixed tokens AKIA[0-9A-Z]{16} Fixed-prefix keys are the most reliable to match.
Private key headers -----BEGIN [A-Z ]*PRIVATE KEY----- An exact, low-noise signal.

Before you run a pattern across an entire history, get it right on a sample. Paste a few example lines and your expression into the regex tester to see live what matches and what does not, tighten it to cut false positives, and only then feed it to git grep or a scanner. Because that tool runs in your browser, you can safely test against a real leaked string without sending it anywhere. Note the trade-off: patterns with fixed prefixes (like a private-key header) are precise, while generic “long random string” patterns catch a lot of innocent hashes and IDs. Regex is a first pass, not a guarantee — high-entropy secrets with no fixed format will slip through, which is why dedicated scanners also use entropy scoring and why a pre-commit hook that blocks secrets before they are committed is the real fix.

What is the first thing to do after a leak — clean up or rotate?

Rotate the secret first, always. Rewriting history is slower and does not recall any copy an attacker may already have grabbed, so the priority is to make the leaked value worthless. That means going to the provider and revoking or regenerating the credential:

  1. Revoke the exposed secret at its source — delete the API key, reset the password, or invalidate the token in the provider’s console.
  2. Issue a replacement and deliver it through a proper channel: a secrets manager, environment variables injected at deploy time, or your platform’s encrypted config — never another commit.
  3. Check for misuse in whatever access or audit logs the provider offers, since the old value may already have been used.

Only once the secret is dead does removing it from history become a matter of tidiness and preventing re-leaks, rather than an emergency. Reversing the order is a common mistake: people spend an hour rewriting history while the still-valid key sits exposed the whole time.

How do I remove a secret from Git history?

You rewrite history so that no commit anywhere contains the secret, which necessarily changes commit hashes. There is no way to surgically edit one old commit without altering it and every commit built on top of it, because each commit hash is derived from its content and its parent. The common tools are git filter-repo (the current recommendation) and, historically, BFG Repo-Cleaner. A filter-repo run to strip a file or replace text looks like:

# remove a whole file from every commit
git filter-repo --path config/secrets.yml --invert-paths

# or replace a specific string everywhere it appears
# (put "OLD_SECRET==>REDACTED" lines in a file passed to --replace-text)
git filter-repo --replace-text replacements.txt

After the rewrite, the local history is clean but diverged from the remote. Completing the cleanup means:

  • Force-push the rewritten branches and tags to the remote.
  • Tell every collaborator to re-clone or hard-reset, because their existing clones still carry the old commits with the secret.
  • Ask the host to expire caches. Some platforms retain unreferenced commits or fork copies for a while; a force-push alone may not immediately unreachable every trace.

None of this restores the secret’s safety — it only stops future readers of the history from finding it. That is why rotation, not rewriting, is the step that actually protects you.

How do I stop it happening again?

Keep secrets out of the repository in the first place, and add automated tripwires. A few durable habits:

  • Git-ignore secret files like .env and provide a committed .env.example with placeholder values only.
  • Load secrets from the environment or a secrets manager at runtime, so the code references a name, not a value.
  • Run a secret scanner in a pre-commit hook and in CI so a matching pattern blocks the commit or fails the build before anything is pushed.
  • Prefer short-lived credentials where the provider supports them, so even a missed leak expires on its own.

You can prototype the detection rules for those hooks with the same regex tester, refining each pattern against real examples until it flags the tokens you care about without drowning developers in false positives.

Key takeaways

Version control remembers everything, so a committed secret is exposed the instant it is pushed and is not saved by a later deletion. Find existing leaks by scanning both the working tree and the full history — the pickaxe search matters because deleted secrets still live in old commits — using patterns you have validated on real samples. When you find one, rotate it first: revoke and replace the credential so the leaked value is worthless. Then, and only then, rewrite history with a tool like git filter-repo, force-push, and have everyone re-clone. Finally, keep secrets out of the repo with ignore rules, runtime injection, and an automated scanner so the cycle does not repeat.

Frequently asked questions

If I delete a secret in a new commit, is it gone?

No. The secret still exists in every earlier commit that contained it. Anyone who can read the history — or already cloned the repository — can recover it. Deleting it going forward does not remove it from the past.

What should I do first when a secret is leaked?

Rotate it. Assume the secret is already compromised and revoke or replace it at the provider before anything else. Rewriting history takes time and does not undo any copies attackers may already hold.

How do I remove a secret from Git history?

You have to rewrite history so the secret never appears in any commit, using a tool such as git filter-repo. Rewriting changes commit hashes, so you then force-push and every collaborator must re-clone or reset.

Can regex catch every secret?

No. Regex reliably catches structured tokens with fixed prefixes or lengths, but high-entropy or unformatted secrets slip through. Combine pattern matching with entropy checks and a pre-commit scanner rather than relying on regex alone.

Do private repositories make committed secrets safe?

No. Access can widen, forks and clones persist, backups are made, and a repository can be accidentally made public. A committed secret should always be rotated regardless of the repository being private.