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...
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 --versionto check yours. - Python 3.6 or newer, since
git filter-repois a Python tool. This tutorial used Python 3.13.14. - Basic familiarity with everyday Git: you should already know what
git add,git commit, andgit pushdo, 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.








No Comment! Be the first one.