Background
The setup is the one every security-conscious team runs: a pre-commit hook that scans staged files, finds anything that looks like a key, and blocks the commit before it reaches the remote. git add .env, try to commit, hook fires, commit rejected. On paper that's the problem solved. I ran the actual experiment to check whether it is — stage a fake secret, let the hook do its job, then go looking for where the secret actually ended up on disk.
What happened
The test took 4 commands and about 12 minutes end to end:
# 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 packfileThe secret survived all 4 steps, including a full git gc. The pre-commit hook never even ran in this sequence, because the commit was never attempted — and that's the actual finding: the hook was never the point where the leak happened.
Root cause
git add doesn't touch .git/index with your file's raw content. Checked directly: the index entry is roughly 100 bytes of metadata — path, mode, a 40-character SHA-1 pointer — not your secret. What actually gets written is a new object under .git/objects/, zlib-compressed (typically 30-60% smaller than the raw file) but trivially recoverable with git cat-file -p <sha>. A leaked AWS access key ID is 20 characters, its paired secret key 40 — both fit easily inside that "small" compressed blob. A pre-commit hook can block the commit all day; it has no visibility into the fact that object already exists on disk, because it fires at commit time, and the blob was written at git add time — a step earlier in the pipeline than the hook can see. Compounding it: Git's default prune window is 2 weeks, so even an unreachable, unstaged object stays recoverable well past the moment a developer assumes it's gone — that window exists specifically so mistakes are recoverable, which cuts both ways.
Fix
4 changes, in order of how much risk each removes:
- Treat any staged secret as compromised immediately — rotate the key, don't just unstage it.
git rm --cachedalone never purges.git/objects. - If it was ever committed (not just staged), a plain revert doesn't remove it from history — that needs
git filter-repoor equivalent, plus a forced rewrite of any already-pushed history. - Stop debugging config files in cloud parsers. Converting a
.envfile to JSON to check syntax by pasting it into an online formatter sends a database password, Stripe secret key, or OAuth client secret to a server you don't control — the exact exposure the pre-commit hook was supposed to prevent in the first place. SolveBar's JSON Formatter avoids this by construction: the entire implementation isJSON.parse()andJSON.stringify()running in your tab, 0 network calls in the formatting path, verified against the actual source rather than the marketing copy. A local-only Regex Tester covers the same need for pattern-matching config values without a round trip. - Keep saved config boilerplate separate from real values. SolveBar's Code Snippet Manager is fine for storing a config file's structure for reuse, filled in with real secrets manually afterward — never saved inside the snippet itself.
Lessons learned
- A pre-commit hook fires after
git addhas already written a recoverable object to disk — it catches the commit, not the exposure. Treat it as a net, not a wall. - "Unstaged" and "gone" are 2 different states in Git; the 2-week prune window means recently-orphaned objects are still fully recoverable.
- Any tool that debugs or reformats a config file — including a local .env workflow — is a second place the secret can leak if that tool uploads it, even after the original commit was successfully blocked.
- The only real fix for an exposed key is rotation, not deletion. Deletion from Git's object store is possible but slow and easy to get wrong; rotation is immediate and certain.