Open the service connections list on an Azure DevOps project and check how each one authenticates. Where the answer is a client secret, there is an expiry date a year or two out, and often nobody left who remembers creating it.
Nothing is wrong with it. It works, it has always worked, and the renewal lands on whoever happens to be around the week it expires. That is the whole design, and there are now numbers on how badly it holds up.
What the 2026 numbers say
GitGuardian's State of Secrets Sprawl 2026 counted 28.65 million new hardcoded secrets added to public GitHub during 2025, up 34% year on year. Commit volume grew faster than that, so the rate is arguably flat. The findings that matter are elsewhere in the report.
59% of the compromised machines in their incident data were CI/CD runners rather than personal laptops. That inverts how most security programmes are funded. Endpoint tooling, conditional access, device compliance and phishing training all point at the laptop, while the build agent holding a credential that can rewrite production runs in a subscription nobody has audited since it was created.
64% of the valid secrets they found in 2022 were still active in January 2026. Sit with that one. These are not credentials nobody noticed. They were reported to the owner and left working for four years anyway.
Internal repositories are roughly six times more likely than public ones to contain hardcoded secrets. The private repo is where the discipline goes to die, because the exposure feels theoretical right up until someone's account is phished.
Rotation is the wrong control
The 64% figure is the one that should change a design decision, because it says the standard remedy does not get applied even when the problem is handed to someone on a plate.
That is not laziness. Rotating a pipeline credential is genuinely hard work. You
have to find every place the value was copied to, and the honest answer is that
nobody is confident they found them all. A client secret starts life in one
variable group and ends up in a local .env file, a colleague's shell history, a
Confluence runbook written during an incident, and two forks of the repo.
Changing it in the vault breaks whichever copy you forgot, at whatever hour that
pipeline happens to run.
So the ticket stays open and the secret keeps working. The expiry date gets extended, because the alternative is an outage on a Tuesday.
Any control whose cost is paid repeatedly, by a person, on a calendar, will eventually stop being paid. The credential you never have to rotate is the one that does not exist.
Exchange a token instead of storing one
Workload identity federation replaces the stored secret with a trust relationship. The mechanism is worth holding precisely, because it is the part people wave at.
- The external platform issues a short-lived OIDC token describing the workload that is running. That token carries an issuer (who minted it), a subject (which workload, in which project, under which connection) and an audience.
- The pipeline presents that token to Microsoft Entra as a client assertion.
- Entra checks it against a federated identity credential configured on a user-assigned managed identity or an app registration. The issuer, subject and audience all have to match what was configured, case-sensitively.
- On a match, Entra issues an access token for Azure.
Nothing is stored at either end. The trust is a configured match on claims rather than a shared string, so there is no value that can leak, expire, or be pasted into a runbook.
The tokens involved are short-lived and audience-bound. One captured in transit is worthless within the hour and useless against anything else. Compare that to a client secret with a two-year expiry that works from any IP address on earth.
Azure DevOps, and the date now in your calendar
Microsoft has put a clock on the old plumbing, which is the reason to do this now rather than next year.
The Azure DevOps issuer, the one with the https://vstoken.dev.azure.com prefix
in the federated credential, was deprecated on 1 July
2026.
Between July 2026 and June 2027 affected service connections show a warning in
pipeline runs and in the service connection UI. On 1 July 2027 that issuer
reaches end of life. The replacement is the Microsoft Entra issuer, under
https://login.microsoftonline.com/.
It applies to service connections in the Azure public cloud that use single-tenant Entra applications or managed identities. Sovereign clouds and multi-tenant applications are out of scope for now.
So there are two populations to deal with, and they need different work.
Connections still using a secret
Project settings > Service connections, pick the connection, and there is a Convert button. It swaps the authentication scheme to workload identity federation in place, keeping the connection name so no pipeline YAML changes. Conversion is reversible for seven days, which is worth knowing before you start because it makes the change far easier to get approved.
Two constraints catch people. Azure DevOps has to have created the connection originally, since it cannot modify credentials it does not own, and the connection can only be used by one project. Manually created and cross-project connections need the manual route below.
For a project with more than a handful, Microsoft publishes a bulk conversion script that walks every eligible connection in a project and prompts per connection. It needs PowerShell 7.3 and the Azure CLI:
./convert_azurerm_service_connection_to_oidc_simple.ps1 `
-Project "Your-Project" `
-OrganizationUrl https://dev.azure.com/your-org
That turns an afternoon of clicking into one reviewed pass, and it is the single highest-value hour in this whole article.
Connections already federated on the old issuer
These already did the right thing and still have a migration. Azure DevOps flags them in the service connection list with an Update button that moves them onto the Entra issuer. Find them before the warnings become failures during somebody's release; the query further down lists them.
Doing it yourself, with a user-assigned managed identity
Where you cannot create app registrations, or you want the identity itself managed as code, the supported shape is a user-assigned managed identity with a federated credential whose subject names the service connection:
data "azurerm_client_config" "current" {}
resource "azurerm_user_assigned_identity" "deploy" {
name = "id-azdo-deploy"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
}
resource "azurerm_federated_identity_credential" "azdo" {
name = "azdo-sc-azure-prod"
user_assigned_identity_id = azurerm_user_assigned_identity.deploy.id
audience = ["api://AzureADTokenExchange"]
issuer = "https://login.microsoftonline.com/${data.azurerm_client_config.current.tenant_id}/v2.0"
# org / project / service connection name, exactly as they appear in Azure DevOps
subject = "sc://your-org/Your-Project/sc-azure-prod"
}
# Scope the rights to a resource group, not the whole subscription.
resource "azurerm_role_assignment" "deploy" {
scope = azurerm_resource_group.this.id
role_definition_name = "Contributor"
principal_id = azurerm_user_assigned_identity.deploy.principal_id
}
On azurerm 4.81 the credential attaches with user_assigned_identity_id; the older
parent_id and resource_group_name arguments still work but are deprecated.
Then create the service connection with Managed identity, or with Workload
identity federation (manual) and paste in the identity's client ID and tenant
ID. The manual dialog shows you the exact issuer and subject it will send, and
they have to match the federated credential character for character. A case-mismatched project name is the most common reason this fails on first run,
and it surfaces as AADSTS700213, whose message now names the subject it received
and reminds you the comparison is case-sensitive.
The subject claim is what carries the security here, and it deserves more
thought than it usually gets. sc://org/project/connection pins the credential
to one named connection in one project. That is why a production connection and
a pull-request connection should be two identities with different rights, rather
than one connection everyone shares. Pipeline permissions on the connection are
the other half: authorise named pipelines rather than ticking grant access to
all pipelines, or any pipeline in the project inherits production rights.
Runnable version, with the resource group and role assignment included: blog-examples/federated-credential.
GitHub Actions, the same idea
If any part of the estate builds on GitHub, the model is identical and only the
claims change. The issuer becomes
https://token.actions.githubusercontent.com and the subject describes the
repository and branch:
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:your-org/your-repo:ref:refs/heads/main"
The workflow needs permissions: id-token: write, without which no token is
issued and the login fails with an error that does not say so. The client, tenant
and subscription IDs go in vars rather than secrets, because none of them is
one, and moving them makes the repository's empty secret list something you can
check at a glance.
Same caution as the service connection subject: scoped to ref:refs/heads/main,
only that branch qualifies. Scoped to the repository alone, any fork or feature
branch satisfies it too.
Inside Azure it is simpler still
Federation is for callers that live outside Azure. For anything running on Azure compute, a managed identity has always been the answer, and the platform handles the credential lifecycle for you. That is the same principle applied one layer down, and I wrote it up in identity is your security perimeter.
The rule of thumb: managed identity inside Azure, federated credential for callers outside it, a stored secret only where neither is supported.
What Key Vault is still for
None of this retires the vault. Third-party API keys, database credentials for engines that do not speak Entra, signing certificates and licence keys all have to live somewhere, and Key Vault with RBAC and purge protection remains where they belong.
What changes is the size of the problem. A vault manages secrets well. It cannot manage the secrets that were copied out of it, and every credential you delete outright is one that can never sprawl. Get the count down to what genuinely has to be a secret, then guard that set properly.
Find what you are still holding
Two questions worth answering before the end of the week. First, which app registrations still carry a password, and when does each one expire:
az ad app list --all -o json \
--query "[?length(passwordCredentials)>\`0\`]" \
| jq -r '.[] | .displayName as $n | .appId as $a
| .passwordCredentials[] | [$n, $a, .endDateTime] | @tsv' \
| sort -t "$(printf '\t')" -k3
Soonest expiry first, because that is the queue of renewal tickets somebody is already dreading. Work down it converting rather than renewing.
Second, which federated credentials are still pinned to the retiring Azure DevOps issuer:
az ad app list --all --query "[].appId" -o tsv | while read -r id; do
az ad app federated-credential list --id "$id" -o tsv \
--query "[?starts_with(issuer,'https://vstoken.dev.azure.com')].[name,issuer]" \
| sed "s|^|$id |"
done
Anything that comes back is on the July 2027 clock. Managed identities hold their
federated credentials separately, so check those too with az identity
federated-credential list.
Neither query changes a thing, so both are safe to run this morning against production.
This week, in four moves
- Run the first query. The output is your real inventory of stored secrets, and it is usually longer than anyone expects.
- Run the bulk conversion script against your busiest Azure DevOps project. The seven-day revert window is what makes this a low-risk change to propose.
- Split the production service connection from the pull-request one, and authorise named pipelines rather than granting access to all of them.
- Run the second query and move anything still on the
vstokenissuer across, so the July 2027 deadline is already behind you.
Progress here is measured by how few credentials you have left that anyone could leak, not by how well you rotate the ones you kept.