The instinct is right and the fix is wrong. You spot a credential in the code, remove it, commit, and the file is clean. The secret is still in your git history, exactly as readable as it was before — it has only moved from the present into the past.

Why a deleted secret stays in git history

Git does not store a series of edits. Every commit references a complete snapshot of the project at that moment, and every one of those snapshots stays in the repository. When you delete a line and commit, you have added a new snapshot in which the line is absent. Every earlier snapshot is untouched.

So git log -p, or a diff of the commit that removed it, shows the key in full. So does a clone made by anyone at any point. The key is not hidden and not deprecated — it is one command away, permanently.

It gets worse in the normal case rather than the unusual one. If the repository is public, it was cloned and mirrored automatically within minutes of the push, by services that exist to index public code. Some of them are run by researchers; some are not. If it is private, the exposure is smaller but still real: every person and every automated system that has ever had read access retains a copy in whatever clone they made.

The only fix that works

Rotate the credential. Revoke the old one at the provider and issue a new one. That is the fix. Everything else is housekeeping.

This is worth stating flatly because when the removal is done by an AI assistant it will very often report the problem as solved once the line is gone from the working tree — which is true of the file and false of the secret. If you are pasting a finding into an assistant, the instruction to include is: removing a credential from the code is not the same as rotating it, so say it must be rotated with the provider.

Rotation is also the only step whose effectiveness does not depend on knowing who saw it. You cannot enumerate the clones. You can invalidate the key.

What rewriting history does and does not achieve

Tools exist to strip a secret from every commit — git filter-repo is the current recommendation, and BFG Repo-Cleaner is the older one people still reach for — and after rewriting, a fresh clone will not contain it. That is genuinely useful for reducing future exposure, but understand what it does not do:

  • It does not affect existing clones. Anyone who already pulled has the original history.
  • It does not affect forks, and on hosted platforms the old commits typically remain reachable by their hash indefinitely after a force-push — on GitHub, removing them requires contacting Support, and anything in a repository's fork network stays reachable from the forks.
  • It rewrites every commit hash from the change onwards, which breaks every branch, open pull request and build reference downstream. On a shared repository this is a coordinated operation, not a quick fix.

Do it after rotating, if the repository will remain in use and you want the history clean. Never instead of rotating.

Why "we deleted it" is its own detection problem

There is a practical consequence for tooling that is easy to miss. A scanner that only reads the current state of the working tree — the files as they are now — cannot see any of this. It reports the repository as clean, correctly, and misses the entire class.

Detecting it means reading commit diffs rather than files, and the useful design is to keep the two findings disjoint: report a credential still present in the working tree as one kind of finding, and a credential that is only in the history as another. The second is precisely the "we deleted the line and assumed that was the end of it" case, and it deserves its own name because the remediation advice differs — the first needs a code change and a rotation, the second needs only the rotation.

Preventing the next one

Keep credentials out of the repository entirely. Environment variables, a secret manager, or a .env file that is in .gitignore from the first commit — not added to it later, which is how the file gets committed once before anyone notices.

Check pull requests, not just the default branch. This is the highest-leverage moment. A secret caught in a pull request is fixed by rewriting a branch nobody else has pulled; the same secret caught after merge is in history forever and needs a rotation. The difference between those two costs is enormous, and the check is the same check.

Check pull requests with a workflow you have hardened. The check itself runs in CI, so it is worth reading what that pipeline can reach before trusting it with the result.

Commit an example file instead. A .env.example with the variable names and obviously fake values gives new developers what they need without a real key ever being near the repository.

Assume detection is imperfect and prefer short-lived credentials. Keys that expire on their own reduce the value of every leak, including the ones nobody finds.

I deleted a committed API key — is it safe now?

No. Git stores a snapshot for every commit, so deleting the line adds a new snapshot without it while every earlier snapshot keeps the key in full. Anyone can read it with a single command, and anyone who cloned the repository already has it. The only fix that works is rotating the credential: revoke the old one at the provider and issue a new one. Removing it from the code is housekeeping, not remediation.

Does rewriting git history remove a leaked secret?

Only partially. Rewriting strips the secret from the repository so a fresh clone will not contain it, but it does not affect clones that already exist, does not reach forks, and on hosted platforms the old commits typically remain reachable by hash indefinitely after a force-push unless you ask the platform to purge them. It also rewrites every commit hash from that point on, breaking open branches and pull requests. Do it after rotating the credential if you want the history clean — never instead of rotating.

How do I stop secrets being committed in the first place?

Keep credentials out of the repository entirely: use environment variables or a secret manager, and add the .env file to .gitignore in the very first commit rather than later, since adding it later is how the file gets committed once before anyone notices. Commit a .env.example with variable names and obviously fake values so new developers have what they need. Then scan pull requests, because a secret caught before merge costs a branch rewrite instead of a rotation.

Why do scanners miss secrets that were deleted?

Because most scanners read the current state of the working tree — the files as they are now — and a deleted credential is not in any current file. It is in the commit history, which requires reading diffs rather than files. A scanner that only looks at the working tree will correctly report the repository as clean while the key remains one command away for anyone with a clone.

Where CodeControl fits

CodeControl runs two passes over different surfaces: the working tree, and recent commit history. They are kept disjoint — the history finding reports only credentials the working tree no longer contains, which is exactly the "we deleted the line" case. It also checks every pull request against the lines that request adds and posts the verdict as a check run, so the secret is caught while the fix is still a branch rewrite. The value itself is never stored: a finding carries the file, the line, the detector and a masked preview.