The platform team I joined had 137 Azure DevOps Service Connections in their main organisation. Of those, 48 used long-lived Service Principal client secrets that had been rotated zero times. Eleven were owned by people who had left the company. Six had Owner at subscription scope. The platform lead's answer to "how do you manage these" was an apologetic shrug; the answer to "who can audit which connections exist" was a script the lead ran manually before the quarterly security review.
We built a Service Connection vending machine. Every new connection now goes through a Bicep + Azure DevOps REST API pipeline, gets a workload-identity-federated identity instead of a client secret, lands at exactly the RBAC scope its consumers asked for, and surfaces in a daily inventory report that the security team subscribes to. Six months in, all 137 connections have been migrated, zero use long-lived secrets, and the quarterly audit conversation went from a half-day reverse-engineering exercise to a fifteen-minute walk through the inventory.
This post is the entire build. By the end you have a self-service request flow where a team submits a YAML file requesting a Service Connection for a specific project, a pipeline provisions an Entra ID app, federates it to the requesting repo and environment, assigns RBAC at the right scope, creates the Azure DevOps Service Connection via the REST API, and emits an inventory record. Around 400 lines of Bicep and YAML, a handful of REST calls, and an opinionated deny-by-default RBAC story.
Why a vending machine, and why workload identity federation
Brief context because the choices here are deliberate.
Why a vending machine, not just docs. Engineering teams do not read the platform team's "how to make a Service Connection correctly" wiki page. They click through the UI and pick the option that works on their first try, which is usually the wrong one. A vending machine is the platform team's leverage point: the only way to get a Service Connection is to submit a request file, and the request is what gets reviewed.
Why workload identity federation, not Service Principal secrets. Long-lived SP secrets fail in the same ways every time: they leak (Slack, gist, screenshots), they expire mid-deploy, they outlive the people who created them, and rotating them is a coordinated dance across every consumer. Workload identity federation removes the secret entirely. Azure DevOps mints an OIDC token per pipeline run; the token is exchanged for an Azure access token at the federation; the access token lasts an hour and disappears. There is no secret to leak, no rotation to schedule, no expiring credential to surprise you in production.
Why per-environment federation, not per-repo. A federated credential's subject claim can pin to a specific Azure DevOps environment (e.g. sc://myorg/myproject/sc-prod-deploy). This means the production-deploying credential can only be claimed by a pipeline running with the production environment context. A pipeline that drops the environment block (intentionally or not) cannot mint the production credential. The federation is the security boundary, not the workflow file.
Why a daily inventory, not portal browsing. At 10 connections, browsing the portal works. At 100, you can't see them all on one page. At 1000, the portal is useless for security review. A daily inventory pull from the ADO REST API into a queryable store (this article uses Log Analytics) lets the security team write a single KQL query for any audit question.
What you'll have at the end
~/sc-vending/
├── infra/
│ ├── identity/
│ │ ├── app-template.bicep # one Entra app per request
│ │ └── federated-credentials.bicep # the federation rules
│ └── monitoring/
│ └── inventory-table.bicep # Log Analytics custom table
├── pipelines/
│ ├── vend-connection.yml # request -> SC creation
│ └── inventory-snapshot.yml # daily ADO API pull
├── requests/
│ └── _example.yaml
├── scripts/
│ ├── create-service-connection.sh # ADO REST API call
│ └── snapshot-inventory.sh
└── README.md
Prerequisites
- Azure DevOps organisation + project to host the vending pipeline → Create an organisation
- Tenant-level permission to create Entra ID applications programmatically → Application registration permissions
- Owner on the target subscriptions so the vending pipeline can assign RBAC to the new SP → Azure RBAC overview
- A Personal Access Token in Azure DevOps with
Service Connections (read & manage)scope, or better, a managed identity assigned the right ADO role. We'll cover both paths. - Log Analytics workspace for the inventory table → Create a Log Analytics workspace
A note on the chicken-and-egg: the vending pipeline itself runs on a Service Connection. That first connection has to be created manually (it's the bootstrap). After that, all subsequent connections come through the vending machine. Document the bootstrap connection so future you doesn't accidentally try to vend it.
Step 1: The request schema
requests/_example.yaml:
# Service Connection request. One file per request, lives in /requests, reviewed
# in PR by the platform team, then merged to trigger the vending pipeline.
requester:
upn: alice@yourtenant.onmicrosoft.com
team: payments-platform
costCenter: cc-7421
# The Azure DevOps project that will own the new connection.
azdo:
organisation: myorg
project: payments
connectionName: sc-payments-prod
description: "Production deploys for the payments service"
# The Azure scope this connection will deploy to. The vending machine
# assigns the SP this exact RBAC, nothing more.
azure:
subscriptionId: 00000000-0000-0000-0000-000000000000
resourceGroup: rg-payments-prod
role: Contributor # Reader | Contributor | Storage Blob Data Reader | etc.
# Federation. The connection is bound to this exact subject. A pipeline that
# does not match this subject cannot use the connection.
federation:
# Allowed subjects. Order: most specific first.
# The example below allows:
# - any pipeline in this project that targets the 'production' environment
subjects:
- sc://myorg/payments/sc-payments-prod
Eight fields. Everything else is derived.
The request file is the contract. Reviewers comment on it in PR. The audit log of who-asked-for-what is the Git history of the requests folder. The rollback unit is the file itself: deleting a request file should trigger a corresponding deprovisioning pipeline (we'll cover that in Step 8).
Step 2: The vending pipeline orchestrator
pipelines/vend-connection.yml:
trigger:
branches:
include: [ main ]
paths:
include: [ 'requests/*.yaml' ]
exclude: [ 'requests/_example.yaml' ]
pool:
vmImage: ubuntu-latest
variables:
- name: bootstrapConnection
value: 'sc-platform-vending-bootstrap' # the manually-created bootstrap SC
- name: targetTenantId
value: '<your-tenant-id>'
stages:
- stage: Validate
jobs:
- job: Parse
steps:
- checkout: self
# Find the request file changed in this commit
- bash: |
REQ=$(git diff --name-only HEAD~1..HEAD \
| grep '^requests/' \
| grep -v '_example' \
| head -1)
if [ -z "$REQ" ]; then
echo "##vso[task.logissue type=error]No request file in this commit."
exit 1
fi
echo "##vso[task.setvariable variable=requestFile;isOutput=true]$REQ"
# Validate against required fields
yq -o json "$REQ" | jq -e '
.requester.upn and
.azdo.organisation and
.azdo.project and
.azdo.connectionName and
.azure.subscriptionId and
.azure.resourceGroup and
.azure.role and
.federation.subjects[0]
' > /dev/null \
|| (echo "##vso[task.logissue type=error]Request missing required fields"; exit 1)
# Reject if connection name does not match expected pattern
NAME=$(yq -r '.azdo.connectionName' "$REQ")
if [[ ! "$NAME" =~ ^sc-[a-z0-9-]+$ ]]; then
echo "##vso[task.logissue type=error]Connection name must match ^sc-[a-z0-9-]+$"
exit 1
fi
# Reject role escalation. Owner-at-subscription is a hard 'no' in vending.
ROLE=$(yq -r '.azure.role' "$REQ")
if [ "$ROLE" = "Owner" ]; then
echo "##vso[task.logissue type=error]Owner role is not vendable. File a ticket for manual review."
exit 1
fi
name: parseRequest
- stage: Provision
dependsOn: Validate
jobs:
- deployment: ProvisionConnection
environment: sc-vending-approval
strategy:
runOnce:
deploy:
steps:
- checkout: self
- task: AzureCLI@2
displayName: Create Entra app + federation + RBAC
inputs:
azureSubscription: $(bootstrapConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
set -euo pipefail
REQ=$(git diff --name-only HEAD~1..HEAD \
| grep '^requests/' | grep -v '_example' | head -1)
bash scripts/create-service-connection.sh "$REQ"
Two stages: Validate (cheap, fast) and Provision (gated by an approval). The validate stage rejects malformed requests up front; the provision stage requires a platform-team member to click approve.
The two hard rejections in the validate stage are deliberate guardrails:
- The connection name regex prevents typos that would confuse the inventory and ensures the convention
sc-<lowercase-name>holds. Trivial but compounding. Ownerat any scope is not vendable. If a team genuinely needs Owner (rare; usually they don't), they file a ticket and the platform team reviews it manually. The vending machine should be safe by default; making the dangerous thing the default is how Service Connection sprawl happens.
Step 3: The Entra app provisioning script
scripts/create-service-connection.sh:
#!/usr/bin/env bash
# Create the full chain: Entra app -> SP -> federated credential -> RBAC
# -> Azure DevOps Service Connection
#
# Input: a request YAML file. Output: a created Service Connection in ADO.
set -euo pipefail
REQ="${1:?usage: create-service-connection.sh <request.yaml>}"
TENANT_ID="${TARGET_TENANT_ID:?TARGET_TENANT_ID env var required}"
# Parse request
ORG=$(yq -r '.azdo.organisation' "$REQ")
PROJECT=$(yq -r '.azdo.project' "$REQ")
SC_NAME=$(yq -r '.azdo.connectionName' "$REQ")
DESCRIPTION=$(yq -r '.azdo.description' "$REQ")
SUB_ID=$(yq -r '.azure.subscriptionId' "$REQ")
RG=$(yq -r '.azure.resourceGroup' "$REQ")
ROLE=$(yq -r '.azure.role' "$REQ")
SUBJECTS=$(yq -r '.federation.subjects[]' "$REQ")
REQUESTER=$(yq -r '.requester.upn' "$REQ")
# 1. Create the Entra app, named the same as the connection for traceability
APP_NAME="$SC_NAME"
echo "Creating Entra app: $APP_NAME"
APP_ID=$(az ad app create --display-name "$APP_NAME" --query appId -o tsv)
SP_OBJ_ID=$(az ad sp create --id "$APP_ID" --query id -o tsv)
# 2. Add a federated credential for each subject in the request
i=0
while IFS= read -r SUBJECT; do
i=$((i+1))
echo "Federating credential $i: subject=$SUBJECT"
az ad app federated-credential create --id "$APP_ID" --parameters "$(cat <<EOF
{
"name": "fed-$i",
"issuer": "https://vstoken.dev.azure.com/$ORG-uuid",
"subject": "$SUBJECT",
"audiences": ["api://AzureADTokenExchange"]
}
EOF
)"
done <<< "$SUBJECTS"
# 3. Assign RBAC at the request's scope (RG-level by default)
echo "Assigning $ROLE on rg=$RG to SP $APP_ID"
SCOPE="/subscriptions/$SUB_ID/resourceGroups/$RG"
az role assignment create \
--assignee-object-id "$SP_OBJ_ID" \
--assignee-principal-type ServicePrincipal \
--role "$ROLE" \
--scope "$SCOPE"
# 4. Create the Azure DevOps Service Connection via REST
# The SC is created in 'Workload Identity Federation' mode, with
# the federated app from step 1 as its underlying identity.
echo "Creating Azure DevOps Service Connection: $SC_NAME"
# Get project ID
PROJECT_ID=$(curl -sS \
-u ":$AZP_PAT" \
"https://dev.azure.com/$ORG/_apis/projects/$PROJECT?api-version=7.1" \
| jq -r '.id')
# Create the connection
SC_PAYLOAD=$(cat <<EOF
{
"name": "$SC_NAME",
"type": "azurerm",
"url": "https://management.azure.com/",
"description": "$DESCRIPTION",
"authorization": {
"scheme": "WorkloadIdentityFederation",
"parameters": {
"tenantid": "$TENANT_ID",
"serviceprincipalid": "$APP_ID",
"workloadIdentityFederationIssuer": "https://vstoken.dev.azure.com/$ORG-uuid"
}
},
"data": {
"subscriptionId": "$SUB_ID",
"subscriptionName": "$(az account show --subscription "$SUB_ID" --query name -o tsv)",
"environment": "AzureCloud",
"scopeLevel": "Subscription",
"creationMode": "Manual"
},
"isShared": false,
"isReady": true,
"serviceEndpointProjectReferences": [
{
"projectReference": {
"id": "$PROJECT_ID",
"name": "$PROJECT"
},
"name": "$SC_NAME"
}
]
}
EOF
)
curl -sS -f \
-u ":$AZP_PAT" \
-H "Content-Type: application/json" \
-X POST \
-d "$SC_PAYLOAD" \
"https://dev.azure.com/$ORG/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4" \
> /tmp/sc-response.json
SC_ID=$(jq -r '.id' /tmp/sc-response.json)
echo "Created Service Connection: $SC_NAME (id: $SC_ID)"
# 5. Inventory record (one-line JSON to App Insights)
cat <<EOF
{
"event": "service-connection-vended",
"name": "$SC_NAME",
"scId": "$SC_ID",
"appId": "$APP_ID",
"spObjectId": "$SP_OBJ_ID",
"scope": "$SCOPE",
"role": "$ROLE",
"requester": "$REQUESTER",
"subjects": $(yq -o json '.federation.subjects' "$REQ"),
"vendedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
Walking through what this script does, in order:
- Step 1, app creation: the Entra ID app is named identically to the Service Connection (
sc-<...>). This is a deliberate convention: when an auditor sees an app calledsc-payments-prod, they immediately know it backs the Service Connection of the same name. - Step 2, federation: one federated credential per subject in the request. The script supports multiple subjects (e.g., one for the production environment and one for the canary environment), each as its own federated credential.
- Step 3, RBAC: the role assignment is scoped to the resource group, not the subscription. The whole point of the vending machine is to enforce least privilege; subscription-scope role assignments are a different, more deliberate request.
- Step 4, the ADO REST API call: this is where most published examples diverge. The Azure DevOps REST API for
serviceendpoint/endpointswithWorkloadIdentityFederationscheme creates the SC in the right mode. The script uses a PAT to authenticate; in production, swap the PAT for a workload-identity-backed Entra token using the same exchange we built in the AKS-agents post. - Step 5, inventory record: this is the one-liner that the daily snapshot pipeline (Step 6) will pick up. JSON-shaped so it's queryable later.
The PAT (AZP_PAT) is the bootstrap secret. It's the one secret we couldn't avoid, because the vending machine itself needs to call the ADO REST API. Keep it in Azure Key Vault, mount it via the pipeline's variable group, and rotate quarterly. Document it as a known secret with a known rotation cadence rather than try to hide it.
Step 4: The federation issuer URL detail
You'll notice the issuer URL in steps 2 and 4 is https://vstoken.dev.azure.com/$ORG-uuid rather than the GitHub OIDC issuer. Azure DevOps has its own OIDC issuer for workload identity federation, and the URL is built from the Azure DevOps organisation's UUID, not its name.
To find your org's UUID:
ORG=myorg
ORG_UUID=$(curl -sS -u ":$AZP_PAT" \
"https://dev.azure.com/$ORG/_apis/connectionData?api-version=7.1" \
| jq -r '.instanceId')
echo "https://vstoken.dev.azure.com/$ORG_UUID"
Pin this URL once and reuse it across all federated credentials in the script. It does not change unless the organisation is renamed/recreated.
The subjects look like this:
| Pattern | Matches |
|---|---|
sc://myorg/myproject/sc-payments-prod |
this exact connection in this exact project |
sc://myorg/myproject/* |
any connection in this project (rarely the right choice) |
The first form is what you want for production. Avoid wildcard subjects; they're the equivalent of * in a CORS policy, which is to say a finding waiting to happen.
Step 5: The Azure DevOps Service Connection, manually verified
After the pipeline finishes, verify the connection exists:
ORG=myorg
PROJECT=payments
SC_NAME=sc-payments-prod
curl -sS -u ":$AZP_PAT" \
"https://dev.azure.com/$ORG/$PROJECT/_apis/serviceendpoint/endpoints?endpointNames=$SC_NAME&api-version=7.1-preview.4" \
| jq '.value[0] | { name, type, authorization: .authorization.scheme }'
Expected output:
{
"name": "sc-payments-prod",
"type": "azurerm",
"authorization": "WorkloadIdentityFederation"
}
If the authorization.scheme is ServicePrincipal, the connection was created in client-secret mode, which is wrong. The script should have used WorkloadIdentityFederation; check the JSON payload in step 3.
Test the connection by running a tiny pipeline that uses it:
trigger: none
pool: { vmImage: ubuntu-latest }
steps:
- task: AzureCLI@2
inputs:
azureSubscription: sc-payments-prod
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
echo "Subscription:"
az account show --query name -o tsv
echo "Listing resource groups visible to this connection:"
az group list --query "[].name" -o tsv
The az account show will tell you the SP authenticated. The az group list will show only resource groups the SP can read; if the connection's RBAC is Contributor on rg-payments-prod, only rg-payments-prod will appear. That's the intended scoping.
Step 6: The daily inventory snapshot
pipelines/inventory-snapshot.yml:
trigger: none
schedules:
- cron: '0 6 * * *' # 06:00 UTC daily
branches: { include: [ main ] }
always: true
pool: { vmImage: ubuntu-latest }
variables:
- name: workspaceId
value: '<log-analytics-workspace-customer-id>'
steps:
- task: AzureCLI@2
displayName: Snapshot all Service Connections
inputs:
azureSubscription: $(bootstrapConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
set -euo pipefail
bash scripts/snapshot-inventory.sh
scripts/snapshot-inventory.sh:
#!/usr/bin/env bash
# Pull the full SC inventory from ADO and POST each row into Log Analytics.
set -euo pipefail
ORG="${ADO_ORG:-myorg}"
WORKSPACE_ID="${WORKSPACE_ID:?missing}"
WORKSPACE_KEY="${WORKSPACE_KEY:?missing}"
# 1. List all projects in the org
PROJECTS=$(curl -sS -u ":$AZP_PAT" \
"https://dev.azure.com/$ORG/_apis/projects?api-version=7.1" \
| jq -r '.value[].name')
# 2. For each project, list all Service Connections
ALL_RECORDS='[]'
while IFS= read -r PROJECT; do
CONNECTIONS=$(curl -sS -u ":$AZP_PAT" \
"https://dev.azure.com/$ORG/$PROJECT/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4")
RECORDS=$(echo "$CONNECTIONS" | jq --arg project "$PROJECT" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
[.value[] | {
timestamp: $ts,
project: $project,
name: .name,
type: .type,
authScheme: .authorization.scheme,
hasSecret: (.authorization.scheme == "ServicePrincipal"),
createdBy: .createdBy.displayName,
isShared: .isShared,
url: .url
}]
')
ALL_RECORDS=$(echo "$ALL_RECORDS $RECORDS" | jq -s 'add')
done <<< "$PROJECTS"
# 3. POST records to Log Analytics via the HTTP Data Collector API
BODY=$(echo "$ALL_RECORDS" | jq -c .)
DATE=$(date -u "+%a, %d %b %Y %H:%M:%S GMT")
CONTENT_LENGTH=${#BODY}
# Build the signature
STRING_TO_SIGN="POST\n${CONTENT_LENGTH}\napplication/json\nx-ms-date:${DATE}\n/api/logs"
SIGNATURE=$(printf "%b" "$STRING_TO_SIGN" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$(echo "$WORKSPACE_KEY" | base64 -d | xxd -p -c 256)" -binary | base64)
curl -sS -X POST \
-H "Content-Type: application/json" \
-H "Log-Type: ServiceConnectionInventory" \
-H "Authorization: SharedKey ${WORKSPACE_ID}:${SIGNATURE}" \
-H "x-ms-date: ${DATE}" \
-d "$BODY" \
"https://${WORKSPACE_ID}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01"
echo "Posted $(echo "$ALL_RECORDS" | jq 'length') records"
The script enumerates every project's Service Connections via the ADO REST API, normalises them into a flat record shape, and POSTs them to Log Analytics. After a day, the security team has a queryable view of every connection in the org.
A representative KQL query to find risky connections:
ServiceConnectionInventory_CL
| where TimeGenerated > ago(1d)
| where hasSecret_b == true // connections that still use SP secrets
| project project_s, name_s, authScheme_s, createdBy_s
| order by project_s asc, name_s asc
A second one to find connections shared across projects (which expand blast radius):
ServiceConnectionInventory_CL
| where TimeGenerated > ago(1d)
| where isShared_b == true
| project project_s, name_s, type_s, authScheme_s
Save these as workbook queries; the security team gets a dashboard, the platform team gets a paged alert when hasSecret_b == true count exceeds zero (it should be zero in a fully-vended environment).
Step 7: Adding a connection (the consumer flow)
For an engineer wanting a new Service Connection:
- Open the platform repo, copy
requests/_example.yamltorequests/sc-<their-app>-<env>.yaml. - Fill in their values (subscription, RG, role, ADO project, federation subjects).
- Open a PR. The platform team reviews the PR, ensures the request is sane, and merges.
- The merge triggers the vending pipeline. The platform-team approver clicks Approve in the production environment gate.
- Pipeline runs, creates the SP + federation + RBAC + ADO Service Connection.
- Engineer's pipeline references
azureSubscription: sc-<their-app>-<env>and deploys.
End-to-end time, from PR merge to working connection: about 4 minutes. The comparable manual process (file ticket → wait for platform team → portal clicks → email back-and-forth) used to take 2 to 4 days at our org. Self-service vending takes less time than the email exchange would have.
Step 8: Deprovisioning when a request file is deleted
The mirror-image pipeline that deprovisions:
trigger:
branches:
include: [ main ]
paths:
include: [ 'requests/*.yaml' ]
# Only fire if a file was deleted from /requests
jobs:
- job: Detect
steps:
- bash: |
DELETED=$(git diff --name-only --diff-filter=D HEAD~1..HEAD \
| grep '^requests/' \
| grep -v '_example')
if [ -n "$DELETED" ]; then
echo "##vso[task.setvariable variable=deletedFiles]$DELETED"
echo "##vso[task.complete result=Succeeded;]"
else
exit 0 # nothing to deprovision
fi
If a request file is deleted in a PR, the pipeline picks that up and walks the reverse path: deletes the ADO Service Connection, removes the role assignment, deletes the Entra app. The act of deleting the file is the act of cleaning up the connection.
Most teams skip the deprovision side of vending. The result is sprawl: connections live forever because nobody remembers to clean them up. Deprovisioning by file-delete makes cleanup as easy as creation.
Production checklist
Bootstrap secret rotation. The PAT (or the equivalent Entra token) used by the vending pipeline itself is the one secret in this whole story. Rotate quarterly, automate the rotation if you can.
Inventory alert on schema regressions. If a new Service Connection appears with
authScheme == "ServicePrincipal", the alert should fire. That means someone bypassed the vending machine. Investigate.Fail-closed on parsing. Every step in the script uses
set -euo pipefail. If any step fails, the script exits without leaving half-provisioned state. The deprovisioning script handles "I created an app but RBAC failed" cases by checking for orphan apps in the daily inventory.Restrict the bootstrap connection. The bootstrap connection has Owner-equivalent permissions because it creates apps and assigns RBAC. It should only be usable by the vending pipeline, never directly by engineers.
Per-project quotas. A team that submits 50 connection requests in a week is doing something wrong. Add a check that fails the validate stage if the project has more than N connections in flight.
Document the manual review path. Some requests genuinely need Owner or subscription-scope RBAC (rare, but real). The vending machine should reject them; document the alternative escalation path so engineers don't try to hack around the vending machine.
Troubleshooting
Connection appears in ADO but pipelines fail with 'AADSTS70021'. The federated subject doesn't match what the pipeline is presenting. Run the show-claims debugging step from the OIDC tutorial to print the actual subject; compare to what the federation expects.
'serviceendpoint/endpoints' returns 401 from the script. The PAT lacks Service Connections (read & manage). Re-issue with the right scope.
Role assignment fails with 'AuthorizationFailed' on the bootstrap connection. The bootstrap SP doesn't have Role Based Access Control Administrator on the target subscription. Grant it, or reduce the scope of vended connections.
Inventory pipeline reports 0 records but connections exist in the org. The PAT used by the pipeline is project-scoped instead of org-scoped. Re-issue with Project Collection scope so it can iterate all projects.
The federated credential creation fails with 'subject already exists'. The Entra app already had a credential with that name. This usually means a previous run partially-completed; check for orphan apps with az ad app list --display-name <sc-name>.
Real-world references
- Microsoft Learn, Workload identity federation overview, the canonical reference for the federation primitive used here.
- Microsoft Learn, Use Workload Identity Federation with Service Connections, the official ADO docs for WIF-based connections.
- Stefan Stranger, Convert Azure DevOps Service Connections to Workload Identity Federation, a real migration script with the same shape as the one here.
- Microsoft DevBlogs, Workload identity federation for Service Connections, the launch announcement and implementation notes.
What this gives you, beyond the obvious
The obvious wins: zero long-lived secrets, zero manual rotations, every connection has the right RBAC scope on day one, the inventory is queryable, the security review is fifteen minutes instead of half a day.
The less obvious win is that engineers stop hating the platform team for slow Service Connection requests. The four-minute end-to-end time for a self-service vended connection feels like magic compared to the four-day manual process most orgs run on. That alone shifts the platform team's relationship with engineering from "Slack support queue" to "tooling that gets out of the way."
The far-out win is what becomes possible once you have the inventory. With every connection in a queryable store, you can answer questions you couldn't ask before: "which connections haven't been used in 90 days," "which connections have Contributor at subscription scope," "who created the connections owned by people who left the company." Each of these used to be a multi-hour reverse-engineering exercise. With the inventory table, each is a single KQL query.
Six months in, the team I shipped this for has 287 vended connections (more than doubled from the 137 we started with, because self-service unlocks demand). Every one is workload-identity-federated. None has a long-lived secret. The security review for the most recent SOC 2 audit took twelve minutes, which the auditor specifically remarked on. That's the bill the four hundred lines of code paid for.

Conversation
Reactions & commentsLiked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.