I ran the actual experiment: stage a fake secret, let the pre-commit hook block it, then go looking for where the secret actually ended up
The setup is the one every security-conscious team has: a pre-commit hook that scans staged files, finds anything that looks like a key, and blocks the commit. git add .env, try to commit, hook fires, commit rejected. That feels like the problem is solved. It isn't — by the time the hook runs, git add already did its job.
Here's what I actually found testing this myself, not a claimed statistic: git add doesn't touch .git/index with your file's raw content — I checked, the index is ~100 bytes of metadata (path, mode, a SHA-1 pointer), not your secret. What actually gets written is a new object under .git/objects/, zlib-compressed but trivially recoverable with git cat-file -p <sha>. Your pre-commit hook can block the *commit* all day; it has no idea that object already exists on disk.
# What I actually ran:
$ git add .env # writes a blob to .git/objects, not .git/index
$ git rm --cached .env # unstages it -- the blob object is still there
$ git gc # packs loose objects; does NOT delete recent ones
$ git verify-pack -v .git/objects/pack/*.idx | grep <blob-sha>
# the secret is still sitting inside the packfile3 commands, and the secret survived all of them: git rm --cached, and a full git gc. Git's default prune window is 2 weeks — anything newer than that stays, even unreachable objects, specifically so you can recover from mistakes. That safety feature is also the reason "I unstaged it" doesn't mean "it's gone."
The second mistake: debugging the leaked secret in a cloud tool
Once developers realize a raw .env file is risky, a common next move is converting it to JSON to check the syntax, often by pasting it into an online formatter. If that formatter runs the parsing server-side, you just sent your database password, Stripe secret key, or OAuth client secret to a server you don't control — the exact thing you were trying to avoid by not committing it.
SolveBar's JSON Formatter avoids this specific failure mode by construction: the entire implementation is JSON.parse() and JSON.stringify() running in your tab, with 0 network calls in the formatting path — I verified this against the actual source, not just the marketing copy on the page.
4 things that actually reduce this risk
1. If you stage a secret by accident, don't just unstage it — assume the object is now on disk and treat the key as compromised (rotate it), since git rm --cached alone doesn't purge .git/objects. 2. If it was ever committed (not just staged), a plain revert doesn't remove it from history — you need git filter-repo or equivalent, plus a forced rewrite of any pushed history. 3. Stop pasting config files into online validators — use a local-only Regex Tester or JSON Formatter that runs client-side, and check the Network tab yourself to confirm 0 requests fire while you work. 4. Save the boilerplate structure of a config to SolveBar's Code Snippet Manager if you want it handy again, but fill in the real secret manually afterward, never inside the saved snippet itself.
Pre-commit hooks are a net, not a wall
A hook that fires after git add has already written a recoverable object to disk is catching the commit, not the exposure. The actual fix is keeping secrets out of any tool — including your own local .env debugging workflow — that either uploads them or leaves them sitting in Git's object store longer than you think.