I committed a secret: the procedure
Intermediate Core Git Git CLI GitLab UI GitHub UI
Why this matters
It happens to careful people: a .env staged by git add ., a token pasted into a config file for five minutes, a private key copied into the wrong folder. The instinct is to delete the file and commit that. This lesson exists because that instinct is wrong in a way that feels right, and because the step that actually helps takes one minute.
The procedure
- Rotate the secret. Revoke it wherever it lives — the token page, the cloud provider, the database — and issue a new one. Assume that from the moment it was pushed, the old value is public: repositories are cloned, mirrored, backed up and scanned by bots within minutes. Nothing else you do makes the old value safe. Only this does.
-
Stop tracking the file, and add it to
.gitignoreso it cannot come back:Terminal $ git rm --cached config.env $ printf 'config.env\n' >> .gitignore $ git add .gitignore $ git commit -m "chore: stop tracking config.env" -
Decide whether to purge history. Needed if the repository is public, or shared beyond the people already trusted with the secret, or if a policy requires it. Not needed for a private repository whose members were all entitled to the value anyway — once it is rotated, the old string is worthless.
- If purging: rewrite and force push, having warned everyone (below).
- Tell the team and check the platform's secret-scanning alerts, so nobody is surprised by a rotated credential and any automated finding is closed.
Why step 2 is not enough on its own
This is worth seeing rather than being told. A repository where a secret was committed, then removed:
$ git log --oneline5276747 chore: stop tracking config.env
211b1cb Update data
afe2b4f Add config
4741f73 Add dataThe file is gone from the working tree and from the latest commit. And yet:
$ git show HEAD~1:config.envAPI_KEY=sk-live-9f8e7d6c5b4aEvery earlier commit still contains it, because that is what a commit is: a complete snapshot (lesson 2.4). Anyone with the repository can read the value with one command. Finding where it entered is just as easy:
$ git log --oneline -S "sk-live-9f8e7d6c5b4a"5276747 chore: stop tracking config.env
afe2b4f Add config-S searches history for commits that added or removed a string. Attackers run the equivalent automatically; so should you, when auditing.
Purging history
Rewriting history to remove a file is the nuclear option, and it is disruptive: every commit after the removal gets a new hash, so every colleague's clone diverges and every open merge request is affected. Do it only when step 3 says it is necessary, and after rotating.
The tool is git filter-repo (a separate install; the old git filter-branch is deprecated and slow):
$ git filter-repo --invert-paths --path config.envOr the BFG Repo-Cleaner, which is faster for the common cases and can replace strings rather than whole files. Both rewrite the whole history, and then:
$ git push --force --all
$ git push --force --tagsAfterwards, ask the platform to clean up: cached views, merge request diffs and forks can retain the old objects. GitLab and GitHub both have a support process for purging unreachable objects, and forks are a separate problem entirely, because you do not control them.
How to do it
The whole procedure in commands, with the rotation done on the website first:
$ git rm --cached config.env
$ printf 'config.env\n' >> .gitignore
$ git add .gitignore && git commit -m "chore: stop tracking config.env"
$ git push
$ git log --oneline -S "the-secret-string" # confirm where it livesThen, only if step 3 applies, git filter-repo and a coordinated force push.
Nothing special: use the terminal for the removal, and the Source Control view to confirm the file is gone and greyed out afterwards.
VS Code does not rewrite history; filter-repo is a terminal tool.
Same: remove the file with Git → Remove from VCS (which runs git rm --cached), add the ignore rule, and commit.
IntelliJ's Log tab is useful for step 3: right-click a file → Show History shows every commit that touched it, and the search box accepts text to find commits by content.
Rotate first: if the secret is a GitLab token, delete it on the token page, which takes effect immediately.
GitLab's secret detection may already have raised a finding under Secure → Vulnerability report; close it once rotated. For purging, GitLab documents a repository cleanup process that takes an object list produced by filter-repo and removes the unreachable objects from the server.
If the project has forks, they keep their own copies; that is a conversation with their owners.
Rotate on the token page or with the provider. GitHub's secret scanning may have detected it already and, for many providers, notifies the provider automatically so that the credential is revoked without you — helpful, and not something to rely on.
Push protection blocks pushes containing recognized credential formats. If you hit it, the right response is to remove the secret from the commit, not to use the bypass option.
Forks retain the old objects; GitHub support can help with a cleanup request after a rewrite.
Common mistakes
- Deleting the file and committing, then relaxing. The value is in the earlier commit and reachable in one command.
- Purging history before rotating. Hours of disruption and the credential still works.
- Amending the commit and force-pushing quietly. Colleagues who already pulled still hold the value, and so does any mirror.
- Assuming a private repository means it did not leak. Members, forks, mirrors, backups and integrations all had it.
- Not saying anything. Whoever else uses that credential deserves to know before it stops working.
Try it yourself
Goal: see for yourself that a removed file is still in history, in a throwaway repository.
- In a scratch folder outside the playground:
mkdir ~/leak-demo && cd ~/leak-demo && git init. printf 'API_KEY=sk-live-demo123\n' > config.env, thengit add .andgit commit -m "Add config".- Make another commit that removes it:
git rm --cached config.env,printf 'config.env\n' > .gitignore,git add .gitignore,git commit -m "Stop tracking config". - Now run
git show HEAD~1:config.envandgit log --oneline -S "sk-live-demo123". - Delete the folder:
cd ~ && rm -rf leak-demo.
Expected result: step 4 prints the secret and lists the commits that touched it, although the file no longer exists in the latest commit.
Show solution
Two commands, and the "deleted" secret is on screen. That is the whole argument for rotating first: the value is recoverable by anyone with the repository, and the only action that changes that is making the value useless.