The single most common security mistake in small teams is not a clever attack. It is a developer, at some point in the first six months, committing a file with a live credential in it, noticing a week later, and deleting it.
Deleting it feels like a fix. It is not. Git keeps every version of every file it has ever tracked, which is the whole point of git. The commit that introduced the key is still there, still reachable, still readable by anyone who can read the repository.
This is the mirror image of secrets leaking into your public site: same credential, different hiding place, and this one is invisible to anything scanning from outside.
Why removal does not remove
A git repository is a chain of snapshots. When you delete a file and commit, you add a new snapshot in which the file is absent. Every earlier snapshot is untouched, and a single command retrieves any of them.
Which means the credential is available to:
- everyone with access to the repository now;
- everyone who had access at any point, including people who have left and still have a clone on a laptop;
- anyone at all, immediately, if the repository is ever made public, because the history goes public with it;
- any backup, fork or mirror created in the meantime.
That last route catches teams out. A repository made public years after the commit exposes the entire history at once, and automated scanners watching public repositories are fast.
Finding what is already in there
Before changing how you work, find out what you are carrying. Two approaches, and it is worth doing both.
Ask your host. GitHub scans public repositories for known credential formats automatically and notifies the provider, which is why a key committed publicly is often revoked before you have noticed. For private repositories, secret scanning is available on some plans and worth turning on if you have it.
Scan the history yourself. A dedicated tool reads every
commit rather than the current files. gitleaks and
trufflehog are the common choices, both free and both able to
scan a full history:
# scan every commit in the current repository
gitleaks detect --source . --report-path findings.json
Expect findings, and expect most of them to be uninteresting: test fixtures, example values, public keys. Triage rather than panic. What you are looking for is anything that is or was real.
A rough grep is also better than nothing if you cannot install a tool right now:
git log -p --all | grep -nE "(api[_-]?key|secret|password|token)[\"' ]*[:=]" | head -40
It produces noise and it finds things.
What to do with a real one
Order matters, and the intuitive order is wrong.
- Rotate the credential. Issue a new one, deploy it, revoke the old. Until this is done, the key in your history works, and everything else you do is cosmetic.
- Check for use you did not expect. Most providers log when and from where a key was used. This is your only real chance to learn whether the exposure mattered.
- Then decide about history. With the key revoked, the copy in your commits is a dead string. Rewriting is now a tidiness decision rather than an emergency.
If you do rewrite, understand the cost. Tools like git filter-repo
rewrite every affected commit, changing their identifiers. Everyone with a
clone has to re-clone, open pull requests may break, and anything referencing a
commit hash stops resolving. For a small team on a private repository it is
usually not worth it once the key is dead. For a repository about to be made
public, it usually is.
Stopping the next one
Three layers, in the order they are worth adding.
Make the right thing easy
Most committed secrets come from a developer needing a value to work with and putting it where it is convenient. If getting a working local environment requires hunting for credentials, someone will hardcode one.
Commit a .env.example with the keys and no values, so the shape
of the configuration is in the repository and the values are not. Make sure
.env is in .gitignore from the first commit. Write
down where the real values live, which should be a shared password manager or
your hosting provider's environment settings.
Catch it before it lands
A pre-commit hook that runs a secret scanner on staged changes stops the commit rather than reporting it afterwards. It costs a second per commit. The honest limitation is that hooks live on each developer's machine and can be skipped, so treat this as helpful rather than authoritative.
Enforce it where it cannot be skipped
The scanner that matters runs in CI, as a required status check on your protected branch. It cannot be bypassed by someone in a hurry, and it applies to everyone including whoever set it up. This is where branch protection does real work: the rule only means something because the check is required.
The categories people forget
Secret scanners look for things shaped like API keys. Several genuinely sensitive things are not shaped like API keys and slip through:
- Database connection strings, which contain a username, a password and a host in one convenient line.
- Private keys, committed to make a deployment work once. These at least have a recognisable header.
- Internal hostnames and infrastructure detail, which are not credentials but are useful to someone mapping your systems.
- Customer data in test fixtures. A realistic fixture built by copying real records is a privacy problem sitting in your history indefinitely.
That last one is worth a specific look. It is common, it is rarely thought of as a security issue, and it is the kind of thing that reads badly in an incident report.
Rotating what you find, without breaking things
The instruction to rotate first is easy to write and occasionally awkward to do, because the credential in your history may be in use somewhere you have not thought about.
The sequence that avoids an outage is the same overlap pattern as scheduled key rotation: create the replacement, deploy it everywhere you know of, watch the provider's last-used timestamp on the old key until it stops moving, then revoke.
The difference in this situation is urgency. If the repository is public, or was ever public, treat the key as already compromised and revoke immediately even at the cost of a short outage. Automated scanners watch public repositories continuously and act within minutes, which is why providers so often revoke a leaked key before the developer has noticed.
For a private repository with a small, stable membership, the overlap approach is usually proportionate. The judgement is about who could have seen it, and the honest version of that question includes everyone who has ever had a clone.
The short version
Scan your history once, so you know what you are carrying. Rotate anything real, before you tidy anything. Then make the safe path the easy one and put a scanner in CI where it cannot be skipped. The first part is an afternoon; the rest stops the problem recurring.