TRENDING
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Remove a Leaked API Key From Git History With git filter-repo
Learning Hub

How to Remove a Leaked API Key From Git History With git filter-repo

A hands-on, beginner-friendly walkthrough that reproduces a real leaked API key, purges it from every commit in a Git repository's history with git filter-repo, and blocks the next leak with a tested...

August 26, 2026 18 Min Read
121

You are about to push a commit when you notice a spike in your API usage dashboard. A little digging turns up the cause: three weeks ago, you committed a real API key straight into a config file, pushed it to GitHub, and only just found out. Your first instinct is to delete the key from the file, commit that fix, and move on. That instinct is wrong, and this tutorial exists to show you exactly why, using a real leaked key in a real Git repository that you will build, break, and fix yourself.

Table Of Content

  • Prerequisites
  • Why Git Never Actually Deletes Anything
  • Step 1: Reproduce a Real Leak
  • Create a Practice Repository
  • Commit the Secret, Then “Fix” It the Wrong Way
  • Step 2: Prove the Secret Is Still There
  • Step 3: Scan Your History With a Real Secret Scanner
  • Step 4: The Correct Order of Operations
  • Revoke or Rotate the Key
  • Check for Suspicious Activity
  • Step 5: Fix the Current Code
  • Step 6: Decide Whether You Actually Need to Rewrite History
  • Step 7: Install git filter-repo
  • The Fresh Clone Requirement
  • Step 8: Rewrite History to Remove the Secret
  • Step 9: Verify the Secret Is Actually Gone
  • Step 10: Push the Cleaned History
  • Step 11: The Stale Clone Trap
  • Step 12: What History Rewriting Does Not Fix on GitHub
  • Step 13: Block the Next Leak With a Pre-Commit Hook
  • Test That It Blocks a Secret
  • Test That It Allows Normal Commits
  • Common Mistakes and Gotchas
  • “I Deleted It, So It’s Fine”
  • “The Repository Is Private”
  • Encoding or Renaming Instead of Removing
  • Cleaning Only the Current Branch
  • Forgetting Build Artifacts
  • The Wrong Default Branch on a Fresh Clone
  • How to Verify Everything Worked End to End
  • Next Steps

An API key (also called a secret, a token, or a credential) is a string that proves your application is allowed to talk to some other service: a payment provider, a cloud platform, an email API, a database. If that string ends up in a public place, anyone who finds it can use it as if they were you, often silently, until you notice unexpected usage or a bill. Git history is the full record of every commit ever made in a repository, going back to the first one. Deleting a line from a file and committing that change does not touch history: the old commit that added the secret is still there, and Git can retrieve the exact file contents from that commit at any time. That is the trap this tutorial is about.

By the end of this post you will have personally: leaked a fake credential into a Git repository the way it really happens, proven with real commands that deleting it in a later commit does not remove it, run an actual open source secret scanner against the history to catch it, rewritten that history with git filter-repo to permanently scrub the secret, watched what happens to a stale clone that still has the old history, and built and tested a pre-commit hook that blocks the next leak before it ever reaches a commit. Every command below was run for real while writing this post. The output shown is what actually printed, not a guess at what should print.

Prerequisites

  • Git 2.36 or newer. This tutorial was built and tested on Git 2.55.0. Run git --version to check yours.
  • Python 3.6 or newer, since git filter-repo is a Python tool. This tutorial used Python 3.13.14.
  • Basic familiarity with everyday Git: you should already know what git add, git commit, and git push do, even if you have never rewritten history before.
  • A terminal. Everything here works the same on Linux, macOS, and Windows (this tutorial was run on Windows, inside Git Bash).
  • No real cloud account or API key is needed. You will build a local “remote” repository that stands in for GitHub, so nothing you do here touches a real service.

Why Git Never Actually Deletes Anything

Git stores every version of every file you have ever committed as a content-addressed object called a blob. Each commit points to a snapshot of your project (a tree of blobs), and each commit points to the commit before it, forming a chain: your history. When you edit a file and commit the change, Git does not modify the old blob. It creates a brand new blob for the new content and adds a new commit that points to it. The old blob, and the old commit that pointed to it, are still sitting in the repository’s object database, fully intact, as long as any commit or branch still references them.

This is exactly why deleting a secret from the latest version of a file does nothing to protect it. The commit that introduced the secret is unchanged. Anyone with a clone of the repository, including automated scrapers that watch public GitHub pushes in real time, can check out that old commit and read the file exactly as it was, secret and all. The rest of this tutorial is about proving that, then permanently rewriting the blobs and commits so the secret no longer exists anywhere in the object database.

Step 1: Reproduce a Real Leak

Create a Practice Repository

You will need two repositories: a bare one to act as your “GitHub,” and a normal working clone. Using a bare local repository instead of a real GitHub repo means you can safely force-push and rewrite history without touching any real account or any real collaborator’s work.

mkdir leaked-key-demo && cd leaked-key-demo
mkdir remote.git && cd remote.git && git init --bare
cd ..
git clone remote.git app
cd app
git config user.email "[email protected]"
git config user.name "Dev Example"
git branch -M main

echo "print('weather app')" > app.py
git add app.py
git commit -m "Initial commit: basic app"

Commit the Secret, Then “Fix” It the Wrong Way

Now reproduce the actual mistake: hardcode a credential, commit it, push it, and then remove it the way most people instinctively do, by deleting it in a later commit.

cat > config.js << 'EOF'
const apiKey = "sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0";
const weatherEndpoint = "https://api.weatherstack.com/current";

module.exports = { apiKey, weatherEndpoint };
EOF
git add config.js
git commit -m "Add weather API integration"
git push origin main

# a few minutes later, the dev notices the key is exposed and "fixes" it
cat > config.js << 'EOF'
const weatherEndpoint = "https://api.weatherstack.com/current";

module.exports = { weatherEndpoint };
EOF
git add config.js
git commit -m "Remove API key from config"
git push origin main

The key used here, sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0, is fake and follows Stripe’s live secret key naming pattern only so the example is realistic. Never use a real key when practicing this.

Step 2: Prove the Secret Is Still There

Look at the current file, then ask Git to look further back. git log -S"<string>" is the pickaxe search: it finds every commit where the number of times that exact string appears in the repository changed, whether the string was added or removed.

cat config.js
git log --all --oneline -- config.js
git log --all -S"sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0" --oneline
git show 7ac08e6:config.js

Here is exactly what printed:

=== current config.js (looks clean) ===
const weatherEndpoint = "https://api.weatherstack.com/current";

module.exports = { weatherEndpoint };

=== git log --all -- config.js ===
1c389aa Remove API key from config
7ac08e6 Add weather API integration

=== git log --all -S search for the leaked value ===
1c389aa Remove API key from config
7ac08e6 Add weather API integration

=== git show of the old commit still has the real key ===
const apiKey = "sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0";
const weatherEndpoint = "https://api.weatherstack.com/current";

module.exports = { apiKey, weatherEndpoint };

Notice that the -S search returned both commits, the one that added the key and the one that removed it, because both changed the occurrence count. It is a common mistake to assume -S only points you to where a secret was introduced. It points to every commit where the count changed, so you still need git show <commit>:<file> to actually read the contents at a given point in time, which is what the last command did. The current file looks perfectly clean. The commit two steps back does not.

Step 3: Scan Your History With a Real Secret Scanner

You do not have to search for secrets by hand. Gitleaks is a free, open source secret scanner that walks your entire Git history, not just the working directory, and matches content against known credential formats. Install it from the releases page (a single binary, no dependencies) and point it at the repository:

gitleaks detect --source . -v --no-banner

Real output against the demo repository from Step 1:

Finding:     const apiKey = "sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0"
Secret:      sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0
RuleID:      stripe-access-token
Entropy:     5.028992
File:        config.js
Line:        1
Commit:      7ac08e6a9d31af614f0bcbd31904410ed78fcdd4
Author:      Dev Example
Email:       [email protected]
Date:        2026-08-26T10:40:20Z
Fingerprint: 7ac08e6a9d31af614f0bcbd31904410ed78fcdd4:config.js:stripe-access-token:1

3 commits scanned.
scanned ~228 bytes (228 bytes) in 133ms
leaks found: 1

Gitleaks correctly identified the key’s sk_live_ prefix as Stripe’s live secret token format and gave a specific commit, file, and line number, exactly as if this were a real repository you inherited and had no idea what was buried in it. This is the tool you would actually run on day one of a real incident, and it is also what teams wire into CI so a leak gets flagged automatically on every push.

Step 4: The Correct Order of Operations

Before touching Git history at all, do these two things first. GitHub’s own documentation makes the same point directly: “if the sensitive data you need to remove is a secret (e.g. password/token/credential)… as a first step you need to revoke and/or rotate that secret. Once the secret is revoked or rotated, it can no longer be used for access, and that may be sufficient to solve your problem.”

Revoke or Rotate the Key

Go to the dashboard of whatever service issued the credential and revoke it, or generate a replacement and disable the old one. Do this before you do anything else. Rewriting Git history takes time and coordination; an exposed key stays dangerous for every second it remains valid, and a scraper may have already copied it long before you noticed the leak.

Check for Suspicious Activity

While the key is still visible in the provider’s logs, check its usage: unexpected request volume, requests from unfamiliar locations or times, resources you did not create. Write down a rough timeline. If the credential could touch billing, check that too. This is the difference between “we rotated a key” and “we rotated a key and know whether it was actually used.”

Step 5: Fix the Current Code

With the key rotated, fix the code so the new credential never gets hardcoded in the first place. Load it from an environment variable, keep the real value in a .env file that Git ignores, and commit a .env.example so teammates know what variable name to set without ever seeing a real value.

cat > config.js << 'EOF'
const apiKey = process.env.WEATHER_API_KEY;
const weatherEndpoint = "https://api.weatherstack.com/current";

module.exports = { apiKey, weatherEndpoint };
EOF

cat > .env.example << 'EOF'
WEATHER_API_KEY=your-weather-api-key-here
EOF

cat > .gitignore << 'EOF'
.env
node_modules/
EOF

echo "WEATHER_API_KEY=sk_live_51NEWKEYaftertheincidentXXXXXXXXXX" > .env

git add config.js .env.example .gitignore
git commit -m "Load API key from environment instead of hardcoding it"
git push origin main
git status --short

git status --short prints nothing for .env, confirming it is correctly ignored and will never be committed. At this point, going forward, the repository is safe. What is still unsafe is everything behind you: the old, still-fully-readable commit from Step 1.

Step 6: Decide Whether You Actually Need to Rewrite History

Rewriting history is disruptive. It changes commit hashes, breaks open pull request diffs, and requires every collaborator to reset their local clones (you will see exactly how, and exactly why it matters, in Step 11). GitHub’s documentation is explicit that you should weigh this cost, and lists the real side effects: high risk of the secret coming back if a stale clone gets pushed, changed commit hashes breaking any tooling that depends on them, broken diff views on closed pull requests, and lost commit signatures. Ask yourself:

  • Was the secret ever pushed to a shared or public location? If it only ever existed in a local commit you never pushed, you can simply amend or rebase it away locally. No one else ever saw it.
  • Is the secret still valid? If you already revoked it in Step 4, the leaked string in history is no longer usable for anything. Some teams stop right there and treat the rewrite as optional cleanup rather than an emergency. Others still rewrite history to keep old, no-longer-secret values out of the codebase for hygiene and audit reasons. This tutorial rewrites it, since that is the scenario most guides (and most real incidents) actually walk through.

For this tutorial, assume the worst case: the repository was public and the key was live when it leaked. That means history needs to be rewritten.

Step 7: Install git filter-repo

git filter-repo is the tool GitHub’s own documentation recommends for this, in a section titled “Purging a file from your local repository’s history using git-filter-repo.” It replaced the older git filter-branch, whose own official manual now carries a WARNING stating plainly that “its use is not recommended. Please use an alternative history filtering tool such as git filter-repo.” Install it with pip:

pip install git-filter-repo

The Fresh Clone Requirement

Here is a gotcha you will hit immediately if you try to run git filter-repo on the same working clone you have been using all along. Try it in the app directory from Step 1:

git filter-repo --path config.js --invert-paths

Real output:

Aborting: Refusing to destructively overwrite repo history since
this does not look like a fresh clone.
  (expected at most one entry in the reflog for HEAD)
Note: when cloning local repositories, you need to pass
      --no-local to git clone to avoid this issue.
Please operate on a fresh clone instead.  If you want to proceed
anyway, use --force.

This is not a bug, it is a deliberate safety check. git filter-repo assumes you are about to destroy history, and it refuses to do that to a repository with any local reflog activity (stashes, resets, old branches, anything beyond a single clean checkout), because that activity might represent work you have not pushed anywhere else yet. The fix is exactly what the message says: operate on a brand new clone that has never been touched.

cd ..
git clone remote.git app-clean
cd app-clean

Step 8: Rewrite History to Remove the Secret

You have two options here. --path <file> --invert-paths deletes a file from every commit in history entirely, which is the right call when the file should never have existed at all. --replace-text keeps the file but scrubs a specific string everywhere it appears, which is the better fit here, since config.js has legitimate content (the weather endpoint) that should not be deleted along with the secret. Create a replacements file mapping the exact leaked value to a placeholder, then run the rewrite:

echo 'sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0==>WEATHER_API_KEY_REDACTED' > replacements.txt
git filter-repo --replace-text replacements.txt --force

Real output:

NOTICE: Removing 'origin' remote; see 'Why is my origin removed?'
        in the manual if you want to push back there.
        (was C:/.../leaked-key-demo/remote.git)
Parsed 4 commits
New history written in 0.17 seconds; now repacking/cleaning...
Repacking your repo and cleaning out old unneeded objects
Completely finished after 0.42 seconds.

That “NOTICE” is worth understanding, not just clicking past. git filter-repo deliberately removes the origin remote after rewriting history. Its own manual explains why: “It is unfortunately all-too-easy for yourself or someone else to accidentally merge the old ugly history you were trying to rewrite with the new history, resulting in not only the old ugly history returning but getting you ‘two copies’ of each commit (both an original commit and a cleaned-up alternative), and thus doubling the number of commits in your repository.” In other words, it removes the one thing that could let you accidentally pull the leaked history back in and undo all of this. You will re-add the remote yourself, on purpose, in Step 10.

Step 9: Verify the Secret Is Actually Gone

Run the exact same pickaxe search from Step 2 against the rewritten history, and try to look up the old commit that used to contain the key by its original hash:

git log --all -S"sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0" --oneline
git show 7ac08e6:config.js
git log --all --oneline

Real output:

(no output at all from the -S search, meaning zero matches)

fatal: invalid object name '7ac08e6'.

51f067f Load API key from environment instead of hardcoding it
c359d86 Remove API key from config
4a61426 Add weather API integration
d5edfbf Initial commit: basic app

Two things confirm the fix. First, the search comes back empty: the string does not exist anywhere in any commit, on any branch. Second, the old commit hash 7ac08e6 is gone entirely, because rewriting a commit’s content changes its hash, and every commit after it in the chain gets a new hash too (compare this list to Step 2’s: every hash is different from the first commit that touched config.js onward). Checking out the new commit that replaced 7ac08e6 shows the placeholder in its place:

git show 4a61426:config.js
const apiKey = "WEATHER_API_KEY_REDACTED";
const weatherEndpoint = "https://api.weatherstack.com/current";

module.exports = { apiKey, weatherEndpoint };

The real secret is gone. The commit still exists and still tells an honest story of what changed, it just no longer contains anything sensitive.

Step 10: Push the Cleaned History

Re-add the remote that git filter-repo removed, then force-push, since the rewritten commits do not share history with what is currently on the remote:

git remote add origin ../remote.git
git push origin main --force
To ../remote.git
 + a4c52ff...51f067f main -> main (forced update)

The + and the word “forced” in that output are Git telling you exactly what you asked for: a non-fast-forward update that replaces the remote’s history rather than building on top of it. If your real remote has branch protection rules that block force pushes, you will need to temporarily disable that protection for this operation, which is one more reason to treat a history rewrite as a deliberate, coordinated event rather than a quick fix.

Step 11: The Stale Clone Trap

This is the step most guides skip, and it is the single most important thing to understand about history rewrites. The original app clone from Step 1 still exists on disk, and it still has the old, unrewritten history sitting in it locally. Go back to it and look:

cd ../app
git log --all -S"sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0" --oneline
1c389aa Remove API key from config
7ac08e6 Add weather API integration

The secret is still right there, in this clone, on this machine, even though the remote has already been cleaned. This is exactly the scenario GitHub’s documentation warns about: “If a fellow developer has a clone from before your rewrite, and after your rewrite simply runs git pull followed by git push, the sensitive data will return.” Watch what a plain fetch shows:

git fetch origin
From ../remote
 + a4c52ff...51f067f main       -> origin/main  (forced update)
git log --oneline main
git log --oneline origin/main
a4c52ff Load API key from environment instead of hardcoding it
1c389aa Remove API key from config
7ac08e6 Add weather API integration
d5edfbf Initial commit: basic app
---vs remote---
51f067f Load API key from environment instead of hardcoding it
c359d86 Remove API key from config
4a61426 Add weather API integration
d5edfbf Initial commit: basic app

Local main and origin/main now point to two completely different histories that happen to share only the very first commit. A normal git pull here would try to merge these two unrelated timelines, which is precisely the doubled-commit mess the filter-repo manual warned about in Step 8. The correct move is to throw the old history away and replace it with the server’s version, not merge it:

git reset --hard origin/main
git log --all -S"sk_live_51NxTq2eCk9fJp3mZ8vRw7qLtX4bYn0" --oneline
HEAD is now at 51f067f Load API key from environment instead of hardcoding it
(no output from the search: clean)

Every collaborator with an old clone needs to do this same reset, or simply delete their clone and clone fresh, before they touch the repository again. If you cannot reach everyone who has a clone (a departed contractor, a fork you do not control, an old CI cache), the secret can still resurface from their copy. That is why Step 4’s rotation is not optional: it is the only step that fully neutralizes the leak regardless of how many stale copies exist that you cannot control.

Step 12: What History Rewriting Does Not Fix on GitHub

If this were a real GitHub repository, force-pushing the rewritten history is not the end of the story. GitHub’s documentation lists exactly what can still expose the old commits even after a clean force push: any forks of the repository, the commits’ raw content accessible directly by SHA-1 hash in GitHub’s cached views, and any pull requests that reference the old commits. You cannot fix other people’s forks or clones for them; GitHub’s guidance is to send them the same fresh-clone-or-reset instructions from Step 11 so they clean up their own copies. For GitHub’s own cached views and pull request references, the documentation says you have to contact GitHub Support directly, since there is no self-service way to purge those. Budget time for that step if the leaked repository has any forks or a long pull request history.

Step 13: Block the Next Leak With a Pre-Commit Hook

Cleaning up after a leak is expensive. A pre-commit hook that scans staged changes for obvious secret patterns and refuses the commit costs nothing and runs in milliseconds. Install one directly into the cleaned repository:

cat > .git/hooks/pre-commit << 'HOOKEOF'
#!/bin/sh
# Blocks commits that add lines matching common secret patterns.
pattern='(sk_live_[A-Za-z0-9]{10,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|(api|secret)[_-]?key["'"'"']?\s*[:=]\s*["'"'"'][A-Za-z0-9_\-]{16,}["'"'"'])'

found=0
for file in $(git diff --cached --name-only --diff-filter=ACM); do
  match=$(git diff --cached -- "$file" | grep -E '^\+' | grep -vE '^\+\+\+' | grep -inE "$pattern")
  if [ -n "$match" ]; then
    echo "pre-commit: possible secret staged in $file:"
    echo "$match"
    found=1
  fi
done

if [ "$found" -eq 1 ]; then
  echo "pre-commit: blocking commit. Remove the secret or use \`git commit --no-verify\` if this is a false positive."
  exit 1
fi
exit 0
HOOKEOF
chmod +x .git/hooks/pre-commit

This hook only inspects the lines you are actually adding in this commit (git diff --cached filtered to + lines), not the whole file, so editing an unrelated line in a file that happens to contain an old secret elsewhere will not trip it. The regex looks for Stripe-style live keys, AWS access key IDs (the real AKIA prefix, confirmed against AWS’s own IAM identifiers documentation, which lists it as the unique ID prefix for an access key), private key headers, and a generic api_key = "..." or secret_key: "..." pattern for anything else. It is deliberately simple: a real team would reach for a maintained tool like git-secrets or gitleaks itself in a hook (both are explicitly recommended in GitHub’s own documentation), but writing one by hand once is the fastest way to actually understand what it is doing and why it can miss things.

Test That It Blocks a Secret

echo 'const dbPassword = "sk_live_51TestSecretShouldBeBlocked123";' > leaky.js
git add leaky.js
git commit -m "add db config"
pre-commit: possible secret staged in leaky.js:
1:+const dbPassword = "sk_live_51TestSecretShouldBeBlocked123";
pre-commit: blocking commit. Remove the secret or use `git commit --no-verify` if this is a false positive.

The commit never happens. git status afterward still shows leaky.js staged and uncommitted.

Test That It Allows Normal Commits

git reset leaky.js && rm leaky.js
echo "console.log('weather app v2');" >> app.py
git add app.py
git commit -m "Log app version on startup"
[main 0b08f49] Log app version on startup
 1 file changed, 1 insertion(+)

A normal, secret-free change goes through without any friction. That balance, catching the dangerous case while staying invisible on every ordinary commit, is what makes a hook like this something a team will actually keep enabled instead of disabling out of frustration.

Finally, re-run gitleaks against the fully cleaned, hook-protected repository to close the loop:

gitleaks detect --source . -v --no-banner
5 commits scanned.
scanned ~395 bytes (395 bytes) in 110ms
no leaks found

Common Mistakes and Gotchas

“I Deleted It, So It’s Fine”

Step 2 proved this directly: deleting a secret in a new commit leaves it fully intact in every earlier commit. It is the single most common mistake, precisely because it feels like it should work.

“The Repository Is Private”

Private repositories still have collaborators, CI systems, and integrations with read access, and permissions change over time. Treat a leak in a private repo with the same urgency as a public one; only the blast radius differs, not the correctness of the fix.

Encoding or Renaming Instead of Removing

Base64-encoding a secret, moving it to a differently named variable, or splitting the string across two lines does not remove it from history either. All of those are still fully recoverable with git show. The only fix is rotation plus, if needed, a history rewrite.

Cleaning Only the Current Branch

If the secret was ever merged into or branched from another ref (a release branch, a tag, an old feature branch nobody deleted), git filter-repo needs to process that ref too, or the secret survives there untouched. Running it against a truly fresh clone with all branches present, as this tutorial did, is the safest default.

Forgetting Build Artifacts

Compiled bundles, minified JavaScript, and lockfiles can all embed a secret that got substituted in at build time. If any build output was ever committed to the repository, search it too; git log -S and gitleaks both work on any file type, not just source code.

The Wrong Default Branch on a Fresh Clone

While preparing this tutorial, a fresh clone of the practice remote landed on an empty local branch called master instead of checking out the main branch that actually had the commits, because the bare repository’s HEAD symref still pointed at the old default. git status showed “On branch master, No commits yet,” which is an easy thing to misread as a broken clone. The fix was git checkout main. If a fresh clone ever looks suspiciously empty, check git branch -a before assuming anything is wrong with the rewrite.

How to Verify Everything Worked End to End

Before you consider a real leak closed, confirm every one of these, not just the ones that feel most urgent:

  • The original credential shows as revoked or rotated in the provider’s dashboard, and the new credential is only ever read from an environment variable or a secret manager, never hardcoded.
  • git log --all -S"<the leaked value>" on the rewritten repository returns no commits.
  • A secret scanner like gitleaks reports zero findings against the full history, not just the working directory.
  • The remote reflects the rewritten history (check the latest commit hash on the remote matches what you expect locally).
  • Every collaborator with a prior clone has either reset to the new history with git reset --hard origin/<branch> or deleted and re-cloned. Old clones are the most common way a “fixed” leak comes back.
  • If the repository lives on GitHub and had forks or open pull requests referencing the leaked commits, you have contacted GitHub Support to purge the cached views, since a force push alone does not reach those.
  • A pre-commit hook or an equivalent CI check is in place so the same mistake cannot ship again unnoticed.

Next Steps

The hand-rolled regex hook in this tutorial is a solid way to learn the mechanics, but for real projects, wire in a maintained tool instead: run gitleaks or git-secrets as a pre-commit hook through the pre-commit framework, and also enable it in CI so a hook someone bypassed locally with --no-verify still gets caught before merge. If your code lives on GitHub, turn on secret scanning and push protection, which rejects a push containing a recognizable secret pattern before it ever reaches history in the first place, the most effective fix of all since it prevents the leak instead of cleaning up after it. For production credentials, move past .env files entirely and into a dedicated secret manager such as AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault, all three of which GitHub’s own documentation points to directly. And if git filter-repo ever feels like the wrong tool for a specific job, such as needing a GUI or working against a repository too large for a full rewrite, the BFG Repo Cleaner is the other widely used option, though GitHub’s current documentation leads with git filter-repo as the recommended default.

Tags:

API KeysGitGit SecurityGitleaksSecrets Management

Share

A blue Closed sign hanging inside a glass shop door
Previous Post

Minimus’s Shutdown Turns Hardened Container Images Into a Commodity Problem

A person kneeling beside a rocky Alaskan creek pans for gold in a black pan, sifting sediment through the water
Next Post

Have I Been Pwned Finds Half of ShinyHunters’ Carhartt Breach Data Was Synthetic

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Trending
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026