Your Secrets Are Still on GitHub
I regularly look over projects in the crypto ecosystem and jump on calls with friends and people in the space to help set direction, audit code, and build scalable systems. Recently a friend’s project launched so I took a look at the public repo, going through security, perf, and stability. I always check .env and .env.example out of habit and this time I happened to notice that a value had been redacted through git filter-branch.
It wasn’t anywhere in the visual history, but I suspected it had been at some point and that it was definitely live on at least one machine. I also knew there was a chance it was still somewhere on GitHub, even if everything visible had been force-pushed away.
Dangling Commits
When you force-push or use git filter-branch to rewrite history, you’re updating what the branch points to. The old commits become “dangling,” unreachable from any branch or tag. Most people assume that means they’re gone, but they’re not.
GitHub retains these orphaned commits in the repository’s object pool. There’s no documented retention policy or cleanup schedule. GitHub’s own engineering blog describes infrastructure for eventually cleaning up unreachable objects, but there’s no documented timeline or guarantee. Removal requires a support ticket and them determining the severity of the leak. In practice, dangling commits persist for an indeterminate period and are accessible by SHA if you know where to look.
Even better, GitHub’s Events API records every push which includes the SHA that was displaced. So if someone pushes a commit with a private key, panics (understandably), and force-pushes to remove it, the Events API still has the exact SHA of the dangerous commit. And with that SHA you can fetch the commit directly from GitHub, even though it’s not reachable from any branch.
Dedicated secret scanning tools like TruffleHog don’t check dangling commits by default, instead they scan reachable history. TruffleHog does have a github-experimental command that can find dangling commits through SHA enumeration, but it’s a separate alpha-status command that takes 20+ minutes even on small repos.
Building the Scanner
I built a Claude skill to automate this. You point it at a GitHub repo (or an entire org), and it:
- Clones the repo and fetches all refs, including hidden PR refs that a normal clone misses
- Scans every reachable blob for secret patterns: private keys, API keys, mnemonics, database URLs, cloud credentials
- Queries the Events API for orphaned commit SHAs from force-pushes
- Fetches each dangling commit directly from GitHub and scans those too
- For any discovered private keys, derives the public address and checks for on-chain activity
The first time I ran it against my friend’s repo, it found a private key in a dangling commit. A normal scan would have found nothing.
I developed the skill further and ran it across other repos in the organization. Two more API keys and another private key, again only in dangling commits. Of course, before writing up any of this I reached out to them and helped secure the repos.
3 for 3
Since then I’ve run this against a few other organizations. Many have leaked at least API keys through dangling commits. Not all private keys (those are rarer), but API keys for services like Etherscan, Alchemy, and similar. Lower impact than a private key, but still things that shouldn’t be public.
This pattern is a natural outcome as the crypto space evolves with LLMs. It’s extremely easy to put together a project now with secure, well audited smart contracts. The risk though is that the tooling is so simple, it’s very easy to accidentally commit an .env or hardcode a key. Of course you can run your LLM to fix it, but the public dangling commits mean it’s still out there.
Prevention
Set up .gitignore first. Before you write any code, before you create any .env files. This sounds obvious but it’s the number one reason keys end up in history.
Use a private repo as your development environment. Keep a private repo where you do your actual work, and push to your public repo only through a release workflow. I have a GitHub Action that triggers on releases in the private repo and forwards to public. This gives you a few advantages:
- Early on, if you leak keys in the private repo, they’re not public. You can fix history and push clean to public later
- The public repo only contains intentional, finished work. No half-done experiments, no debugging commits
- You can work freely without worrying about what’s visible, then control exactly what goes public
Pre-commit hooks help but aren’t enough. Tools like git-secrets can catch secrets before they’re committed, but they only work if every developer has them installed. Anyone who clones fresh, works on a new machine, or runs git commit --no-verify bypasses them entirely. They’re a good layer, but rarely something you set up on a brand new project.
What To Do If It Happens
If you discover leaked keys in your repo’s history:
- Make the repo private immediately. This is the fastest way to stop the bleeding while you deal with the rest
- Rotate every leaked key. Generate new keys, update your services, invalidate the old ones. This is the only step that actually matters for security. As long as the old key is rotated, it doesn’t matter if someone finds the dangling commit
- Delete and recreate the repo. This clears the object pool and all dangling commits, but only if the repo has no forks. If forks exist, all commits remain accessible through the fork network. GitHub has confirmed this is by design
- Assume the key was compromised. Don’t talk yourself into “nobody probably found it.” Treat it as a confirmed leak and act accordingly
This post includes an agent skill — a structured prompt that teaches coding agents to implement this pattern.