Permanently Remove Sensitive Data from Git History in Azure DevOps

Introduction: The Secret’s Out

That sinking feeling again. You’re reviewing the latest Wiz (or similar security tooling) report when you spot it: a critical API secret sitting in your main website’s Git history. Not in the current code. Not in a config file you can just delete. In Git history.

You rotate the key. You remove the value. You commit the fix. And the alert does not go away.

At this point, panic sets in. Has the scanner cached the finding? Will it disappear on the next scan? Having been in this exact situation recently, it was hugely frustrating to find advice online that was incomplete, conflicting, and occasionally downright dangerous.

This post condenses what I learned into a step-by-step guide on how to permanently remove sensitive data from Git history in Azure DevOps. I’ll also cover a much less common outcome: the long back-and-forth with Microsoft support that ended with one aged repository needing to be retired entirely as an all-or-nothing fix.

Exposed Secrets: The Real World Impact

It’s a scenario that’s played out many times and can have huge consequences for both individuals and organisations. Most recently, a student reported on Reddit that they’d been served a $55k Google Cloud bill after their Gemini API key was checked into their public GitHub repository:

Reddit post headline showing a student hit with a $55,444.78 Google Cloud bill after a Gemini API key was leaked on GitHub.
r/googlecloud – Student hit with a $55,444.78 Google Cloud bill after Gemini API key leaked on GitHub

It culminated in Google ultimately writing off the balance, but after a lot of back-and-forth and worldwide press attention. But you might not get so lucky!

A couple of months later, reports started surfacing online of a security researcher who’d uncovered 17,000 secrets in public GitLab repositories. Here’s the report in Tech Radar:

TechRadar Pro article headline reporting that a security researcher uncovered 17,000 secrets in public GitLab repositories.
Security researcher uncovers 17,000 secrets in public GitLab repositories

There are really important lessons to be learned here. Not only that we should be careful how we store private keys and sensitive data, but also that we should think about our past actions too and take proactive steps to check for and remove any sensitive data from code repositories – especially public ones!

Deleting Isn’t Enough

Git remembers everything. When sensitive data is committed, such as secrets or keys, it stays in the repository’s history.

Security scanners like Wiz take the legwork out of manually iterating through commit history, so we’re in a better place in 2026 to be able to mitigate some of these risks. And if you want to permanently remove sensitive data in Azure DevOps, the only way to do it is to rewrite Git history and force push back to the remote repository.

There is no shortcut and, as we’ll learn from my experience, it doesn’t always work like you’d expect it to.

The Right Tool: git filter-branch vs git filter-repo

In 2026, the most practical and supported way to remove secrets from a Git repository such as those found in Azure DevOps is using git filter-repo. The older method of using git filter-branch now comes with a stark warning that it “has a plethora of pitfalls that can produce non-obvious manglings of the intended history rewrite”:

Git documentation warning stating that git filter-branch has many pitfalls and is not recommended, advising the use of git filter-repo instead.

The newer approach of using git filter-repo is significantly faster, safer, and harder to misuse. But it still comes with risk.

Before You Start

We’ll use a demo repository I created solely for the purpose of demonstrating how to remove sensitive information from an Azure DevOps repository. Using git filter-repo, we’ll rewrite every commit, remove unwanted content everywhere it appears, and push the cleaned history back to Azure DevOps.

This will unavoidably affect all branches, and is a controlled but destructive operation that will require you to:

  1. Back up your existing repository locally in case anything goes wrong or you make a mistake.
  2. Have everyone with a current copy of the repository delete and re-clone afterwards to avoid unwanted history getting pushed back in.
  3. Force push back to Azure DevOps, which requires elevated permissions.

If you’re doing this for the first time, create a test repository to test out the steps and document a short action plan.

The Problem Scenario

For the demo, I added an API secret to appsettings.Production.json in the second commit:

Visual Studio showing Git commit history where a secret was accidentally added to appsettings.Production.json and later removed in a follow-up commit.

Here is the secret MySecretApiKey, which is now part of Git history:

Visual Studio editor showing appsettings.Production.json with a highlighted MySecretApiKey value committed to the repository.

The subsequent “Panic!” commit, and now the working copy following the code, has the secret removed:

Visual Studio editor showing appsettings.Production.json after the secret key has been removed from the file.

This highlights a real-world workflow of sensitive information having been checked into source control, followed by an attempt to remove it. But that didn’t work, we’ll see why, and fix it properly.

Prerequisites

Install git-filter-repo

If you don’t already have Python installed, download the latest version and install it locally. Make sure to add it to your PATH variable, as part of the advanced setup. When you’re done, use pip (Python’s package manager) from the command line to install git-filter-repo by running:

pip install git-filter-repo

You should see “Successfully installed git-filter-repo” message:

PowerShell window showing successful installation of git-filter-repo using pip.

Enable Force Push in Azure DevOps

To remove sensitive data from Azure DevOps repos, you’ll need force push permissions. If you own the project and have never applied any restrictions, then chances are you have what’s required already.

Double check by viewing branches, clicking the three dots to the right of your main branch, and selecting Branch policies:

Azure DevOps branches page showing the Branch policies option highlighted for a repository branch.

Click the Security tab, select your user (or relevant group), and verify that Force push (rewrite history, delete branches and tags) is set to Allow or Allow (Inherited):

Azure DevOps repository security settings showing the Force push permission set to Allow for a user.

If your organisation has customised security, the responsible team will need to enable this for you temporarily until the work is complete.

In the demo below, we’ll run commands on the actual demo repository, but remember to update with your own URLs and settings when using them yourself.

Remove the Azure DevOps Secret Using Git

Let’s take a look now at the steps that will globally remove a sensitive secret from Azure DevOps.

Step 1: Back-up the Repository

Take a clean backup of the repository exactly as it exists right now. This is the most important step, because you’ll need it in case anything goes wrong. It’s not the clone you’ll use for the rewrite, and is distinct from your regular local working copy.

Clone the repository into a clearly labelled backup folder:

# Clone the backup repository
git clone https://dev.azure.com/az/Secrets%20Demo/_git/Secrets%20Demo SecretsDemo-Backup

This will remain untouched until the upcoming history rewrite has completed and the cleaned repository has been verified. If something goes wrong, this clean backup will allow you to recover or compare history without relying on someone else’s potentially old clone.

Step 2: Clone a Disposable Working Copy

Now create the actual clone that will be used to remove the sensitive data. This is a separate, disposable clone – not your normal working copy and not the backup you just created. Run the relevant commands in your console:

# Clone the repository
git clone https://dev.azure.com/az/Secrets%20Demo/_git/Secrets%20Demo SecretsDemo-Cleaned

# Check out the main branch
git checkout main

# Switch to the working directory
cd SecretsDemo-Cleaned

This clone will be deleted once the clean-up is complete.

Step 3: Create a Replacement Rule

For the demo, the leaked MySecretApiKey in the appsettings.Production.json file that was set up earlier is the one we want to disappear:

"MySecretApiKey": "a3c66e55-312c-4611-a372-be415f6ceab6",

We’ll tell Git exactly what we want to remove from the history. In this scenario, it will be the entire line. This is achieved by creating a file called replacements.txt in the root directory of the repository created in step two.

You can either create this file manually, or run this command to create and open the file for editing automatically:

notepad replacements.txt

Whichever way you choose, add the following line to the file and save:

regex:\s*"MySecretApiKey"\s*:\s*".*?",?\s*==>

The rule uses a simple from==>to in order to replace strings. The to side (right of the arrow) is left blank because we want to remove, not replace, in this scenario.

We’re using regex here because it’s really powerful in being able to match complex strings (e.g., removing a <machineKey> node in XML is as simple as regex:\s*<machineKey .* />==>). Simple text replacements work too though, if that’s your requirement.

Step 4: Rewrite Git History

We’re now ready to execute the git filter-repo command to remove the sensitive data from the Git repository in Azure DevOps.

Run the following command to perform the removal:

# Use the replacements.txt file with git filter-repo
git filter-repo --force --replace-text replacements.txt

You should then see the following success messages in the console:

PowerShell output showing git filter-repo rewriting repository history and removing the origin remote as part of the clean-up process.

Here’s what happens when you run git filter-repo:

  • Every commit in the repository is rewritten.
  • All occurrences of the sensitive information is removed.
  • The origin remote is deleted automatically as a safeguard.
  • Remote tracking references are removed.

The reason for the final two points above is to force user intervention and prevent accidental pushes if things don’t go to plan.

Step 5: Verify the Sensitive Data is Removed

Now for the bit: making sure the API key has been removed from Git history. Run the following grep (global, regular expression, print) command on the working tree:

git grep -n "MySecretApiKey"
git grep "a3c66e55-312c-4611-a372-be415f6ceab6"

Both commands should return no output, indicating no results were found:

PowerShell commands running git grep to search for the secret key and API value after the history rewrite.

And then verify removal across all commits. Depending on the size of your codebase, this might take a short time to complete:

git rev-list --all | ForEach-Object { git grep -I -n "MySecretApiKey" $_ -- appsettings.Production.json }

git rev-list --all | ForEach-Object { git grep -I -n "a3c66e55-312c-4611-a372-be415f6ceab6" $_ -- appsettings.Production.json }

Again, you should see no results and it confirms the value doesn’t exist anywhere in the Git history:

PowerShell commands iterating through all Git commits to verify that the secret key and value no longer exist in appsettings.Production.json.

Finally, let’s check the commit where the key was originally checked in:

Visual Studio Git history showing earlier commits where a secret was accidentally added, with the cleaned repository history now excluding that commit.

The line we removed should no longer exist:

Visual Studio editor showing appsettings.Production.json in a cleaned commit with only logging configuration remaining and no secret values present.

Checking more recent commits should also show any other changes have been retained (if any), so everything else was unaffected.

Step 6: Reconnect the Azure DevOps Remote

Once you’re happy that the rewrite completed successfully, re-add the remote:

# Add remote repository
git remote add origin https://az@dev.azure.com/az/Secrets%20Demo/_git/Secrets%20Demo

# List all added remotes
git remote -v

The command window should output the following:

PowerShell output showing the Azure DevOps origin remote re-added and verified after rewriting Git history.

Double check that the URL matches the one for the repository you want to overwrite.

Step 7: Force Push the Cleaned History to Azure DevOps

git push origin --force --all

If the push fails, check whether any branches are locked in Azure DevOps as this can cause problems, depending on your security configuration. If they are, unlock them temporarily before retrying. You can spot them easily as they will have a padlock next to the branch name:

Azure DevOps branches page showing a locked branch highlighted with a padlock icon.

Otherwise, you will see console output similar to the following:

PowerShell output showing a successful force push updating all branches in Azure DevOps after the Git history rewrite.

Step 8: Verify Branches in Azure DevOps

The Git rewrite history will now be visible in DevOps. Verify that branches don’t contain the sensitive information. Now all that’s left to do is re-run your any security scan that may have initially flagged the issue and wait for it to give the all clear.

Step 9: Rotate Keys

If any of the sensitive information checked into source control is still a viable attack vector (i.e., they’re still in use) it’s imperative that they are changed immediately. Whether internal or public repositories, a risk assessment is crucial to determine how quickly to act.

Ghost Commits in Azure DevOps

Sometimes, strange things happen in DevOps that don’t quite make sense.

Azure DevOps Ghost Commits

Whilst on a cleaning spree using a collection of repositories, I spotted that Wiz continued to flag a specific commit ID as containing sensitive data. The commit was visible in the Azure DevOps UI and was referenced consistently in security reports. But no matter what I tried locally, that commit just didn’t exist.

I cloned the repository repeatedly and searched the entire history using every tool I could think of. I rewrote history using git filter-repo, removed files entirely, replaced strings, and even rebuilt the repository using orphan branches. Still, nothing could find the commit locally. The commit ID in Azure DevOps insisted existed wasn’t there. And at that point it became clear it wasn’t a Git problem.

After lots of investigation and head-scratching, the commit was actually found in a completely different repository and existed before the new problematic one even existed. My working assumption is that this was as a result of some overhang from Team Foundation Server (TFS) back in the mid-to-late 2010s, and that the most plausible explanation was that Azure DevOps retained some commit references.

The Outcome

After escalating with Microsoft support and engaging in hours-long troubleshooting, the conclusion was that there was no supported way to remove the commit from Azure DevOps.

The only guaranteed resolution, which was posed after the Microsoft team consulted with DevOps product specialists, was to delete the repository and recreate everything from scratch. This would effectively fix the issue of the ghost commit, but was a less-than-ideal outcome because:

  1. Pull requests were deleted and not transferable.
  2. Builds and releases had to be reconfigured.
  3. Tickets were de-linked from code.

Luckily, Git history was retained so the largest reference point remained.

The Takeaway

In this case, the sensitive data was never present in any code I could access and was likely only part of Microsoft’s internal systems. It existed only in Azure DevOps’ view of the repository, and most probably related to the age of the repo and its original migration history.

The key takeaway for me was that sometimes you can do everything right with Git and still fail to clear a security finding because the problem does not live in Git anymore, but rather the hosting platform.

Knowing when you’re dealing with that scenario can save a lot of wasted time.

Final Thoughts

Leaking secrets into source control happens more often than you might think. Fixing them properly requires a bit more than deleting a file or rotating a key.

Rewriting Git history is almost always necessary, but it’s not always sufficient – as this post shows, older repositories and hosting platforms like Azure DevOps can retain references that no longer exist in Git itself. Always ensure any remediation is verified locally, across all commits, and from a fresh clone. And if Git insists the problem is gone but the platform disagrees, it may not be a Git problem anymore at all.

Leave a Reply

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