Rotate the key before you touch the history. To remove a secret from git history you run git filter-repo and force-push, and in our lab that worked, right up to the moment a teammate with an older clone merged and pushed and the .env file was back on the server with every secret in it.
Getting credentials out of repository history, and wiring in the check that stops the next one, is regular work for the DevOps engineers at CloudSpinx in Kenya. So we tested it on a throwaway repository: M-Pesa Daraja credentials written five ways, two open-source scanners (gitleaks 8.30.1 and TruffleHog 3.99.7) run over it, then a rewrite and a teammate's next pull. Every value was random and nothing went near GitHub.
Do you need to rewrite history at all?
Often not. GitHub's own guide puts it plainly: "as a first step you need to revoke and/or rotate that secret", and once it is rotated, "that may be sufficient to solve your problem." A rotated key sitting in an old commit opens nothing.
The catch for a Kenyan team is who does the rotating. GitHub's secret scanning knows 531 token patterns from 207 providers, and for 157 of those providers, when a token leaks in a public repository, GitHub sends it straight to the issuer, which can then revoke it. We read the whole list of supported patterns: Safaricom is not on it, under that name or M-Pesa or Daraja, and neither is Paystack. Flutterwave's two secret-key patterns raise an alert in the repository but are not sent to Flutterwave. Push protection, which by default covers provider patterns only, has nothing to match a Daraja key against. With no pattern there is no issuer for GitHub to tell, so every credential that was in the file gets replaced by you, at whoever issued it.
If someone used the key to reach customer data before you replaced it, that is a breach question under the Data Protection Act too, with the 72-hour clock running from when you become aware.

Why gitleaks and TruffleHog miss an M-Pesa Daraja key
Neither ships a rule for one. gitleaks has 222 built-in rules and TruffleHog has detectors for Paystack and Flutterwave, and none of them is written for Safaricom's credentials. gitleaks still caught some of ours through its generic rule, which fires when the variable name contains a word like key or secret and the value scores above 3.5 on Shannon entropy. TruffleHog's generic detector is commented out of its default list. TruffleHog found the AWS, Paystack and Flutterwave controls and not one M-Pesa value in any of the five files.
| Where the Daraja credential sat | gitleaks defaults | TruffleHog | gitleaks + 3 rules below |
|---|---|---|---|
.env: consumer key, consumer secret, passkey, security credential | found | missed | found |
.env: MPESA_INITIATOR_PASSWORD, 12 characters | missed | missed | found |
settings.py dict with "consumer_key", "consumer_secret" | found | missed | found |
| Postman environment export | missed | missed | found |
Authorization: 'Basic ...' in the OAuth call | missed | missed | found |
Passkey in a variable named LNM | missed | missed | missed |
The initiator password failed on arithmetic. gitleaks drops a generic match unless its entropy is above 3.5, and entropy per character cannot exceed log2 of the length, so a value of 11 characters or fewer never qualifies and a 12-character one only if no character repeats. Ours had one repeat and scored 3.42.
The Postman export is the one worth checking for tonight. Ours, in Postman's key and value environment format, puts the word consumer_secret in a "key" field and the secret in the "value" field next to it, so the generic rule sees a name with no value and a value with no name, and reports neither. The controls show the scanners themselves were working: both caught the AWS key, and gitleaks filed the Paystack key as a Stripe token, because its Stripe rule matches on the sk_live_ prefix. Its three Flutterwave rules only match test-mode prefixes such as FLWSECK_TEST-, so our FLWSECK- key was caught only by the generic rule, through its variable name.
If your integration keeps a Postman environment export in the repository, run the rules further down against it before anything else, and if they light up, send us the redacted report and we tell you what to replace first.
What git filter-repo removes, and the pull that puts it back
The rewrite itself is two commands, the second of which filter-repo prints for you when it finishes:
git filter-repo --sensitive-data-removal --invert-paths --path .env
git push --force --mirror origin
On our test remote, a bare repository on local disk, the rewrite and a force push did what they say. The branch dropped from three commits to two, and a fresh clone held no trace of the file. The blob was still on the remote's disk, unreachable, waiting to be pruned. GitHub's guide warns that after a rewrite and force push the old commits "may still be accessible elsewhere": "In any clones or forks of your repository", "Directly via their SHA-1 hashes in cached views on GitHub", and "Through any pull requests that reference them". Clearing the cached views and pull request references takes a ticket to GitHub Support, which only assists where it determines that rotating the credential cannot mitigate the risk.
Then the teammate. Their clone predated the rewrite and carried one commit of their own. A plain git pull on git 2.53 refused:
hint: git config pull.rebase false # merge
hint: git config pull.rebase true # rebase
hint: git config pull.ff only # fast-forward only
fatal: Need to specify how to reconcile divergent branches.
Take the first suggestion in that hint, or type git pull --no-rebase, and git merges the old history back in. The push afterwards needed no --force, because the merge sits on top of the rewritten branch, so the remote took it like any other push. A fresh clone then had six commits, the merge included, the .env, and all 8 secrets gitleaks could see in it.
- puts the .env in history
- rewritten, clean
Rebase was safe because git pull --rebase checks whether the upstream branch was rebased since the last fetch, and if so it does not replay the old upstream commits. A merge has no such check, and the merge commit itself holds no .env: it makes B part of the server history again.
git pull --rebase stayed clean. It replayed one commit, the teammate's own, because when the upstream branch has been rebased since the last fetch, git's own docs say the rebase "uses that information to avoid rebasing non-local changes." GitHub's guide says collaborators "must rebase, not merge", and our run shows why. A developer who once set pull.rebase false globally to silence that hint gets the merge without the hint. Even after the clean rebase, the teammate's own object store still held the blob, so the safe instruction to everyone with a clone is the first option filter-repo prints: discard it and clone again.

Rules that would have caught ours
gitleaks takes a config that extends its defaults. These three rules caught everything in the table except the passkey in LNM:
[extend]
useDefault = true
[[rules]]
id = "daraja-postman-value"
description = "Daraja credential in a Postman environment export"
regex = '''"key":\s*"(?:consumer_key|consumer_secret|passkey|security_credential|initiator_password)",\s*"value":\s*"([^"]{8,})"'''
keywords = ["consumer_key", "consumer_secret", "passkey", "security_credential", "initiator_password"]
[[rules]]
id = "daraja-initiator-password"
description = "M-Pesa initiator password, too short for the generic entropy bar"
regex = '''(?i)initiator_?password\s*[=:]\s*["']?([^\s"']{6,})'''
keywords = ["initiator"]
[[rules]]
id = "basic-auth-literal"
description = "Hard-coded HTTP Basic credentials"
regex = '''(?i)authorization["']?\s*[:=]\s*["'`]Basic\s+([A-Za-z0-9+/]{16,}={0,2})'''
keywords = ["basic"]
Run gitleaks git --pre-commit --staged -c gitleaks.toml as a pre-commit hook, and the same rules over full history as a step in a pipeline like the one in our CI/CD pipeline post. The hook is the one that matters, because a secret it stops never needs rotating.
No generic rule finds our 64-character hex passkey under a name like LNM: no keyword list guesses the name, and a pattern for bare 64-character hex flags every SHA-256 digest too. The real fix for that one is the passkey never being in code: read it from the environment, or from a secret store the app queries at start. For cloud credentials you can go further and have no key at all. On a Google Cloud estate we rebuilt, the pipelines started with long-lived JSON keys in repository secrets and finished with 0 static cloud keys, every pipeline federating, which is the same habit a Google Cloud review should check first. An M-Pesa key still has to live somewhere, and the secret store is that place.
When the rewrite is worth doing
Skip it when the repository is private, the credential is replaced and nobody outside the team has a clone. History then holds a string that opens nothing, and the rules above are the whole job.
A public repository, a fork you do not control, or a key that could move money changes that, and the order matters more than the commands. Send us the gitleaks report with --redact on, so no secret leaves your machine, and the list of people and machines holding a clone, through the DevOps scoping form or WhatsApp on +254 713 403 044. We mark every finding replace, rewrite or leave, put them in the order to do it, and hand back the pipeline check that would have stopped the first one. We charge nothing for that reading, and every step in it is one you can do yourself.