In this blog, you will learn how to set up and configure Azure DevOps service connections using workload identity federation through a user-assigned managed identity via an Azure Bicep deployment.
This blog covers:
- A high-level design of how the Azure DevOps services connections are created using Azure Bicep
- The requirements needed to succesfully configure Azure DevOps service connections using workload identity federation
- A deep dive into the Azure Bicep template
About Workload Identity Federation
In short about workload identity federation, Azure DevOps service connections connected to Azure that use workload identity federations are easier to manage and are more secure.
Workload identity federation removes the need for client / secret authentication and also the need to rotate the secrets. Workload identity federation is a one-time setup and there is no need for any rotation. In essence, with workload identity federation you create a trust between the service principal and Azure DevOps.
To learn more in-depth about workload identity federation Microsoft has a great article on this: Microsoft Learn – Workload Identity Federation
High-level design of Bicep deployment
In the image below, you see a high-level design of how the Azure Bicep template is orchestrated and how it manages the creation and configuration of the Azure DevOps service connection using the workload identity federation with the OpenID Connect method.

- An Azure Pipeline deploys/orchestrates the deployment to Azure.
- The first deployment script is used to retrieve information about Azure DevOps such as the
instanceIdand theidof the project where the service connection is going to be created. This script outputs thesubjectIdentifierand theissuer:- subjectIdentifier format:
sc://<AzureDevOps-Organisation-Name>/<AzureDevOps-Project-Name>/<AzureDevOps-ServiceConnectionName> - issues format: [
https://vstoken.dev.azure.com/](<https://vstoken.dev.azure.com/$azureDevOpsOrganisationId>)<AzureDevOps-Organisation-InstanceId>
- subjectIdentifier format:
- A managed identity is created which will be used as the service connection in Azure DevOps. This identity uses the
subjectIdentifierandissueroutputs to configure thefederatedIdentityCredentials. This identity is also assigned the contributor role on subscription level, this is required due to the scope levels of the service connection. - At last, another deployment script is created to create the service connection in Azure DevOps. The deployment script uses the managed identity
clientIdresource output in the arguments of the script. This clientId is used as aauthorizationparameter input for the service connection create call.
The steps above are done during the deployment of the Bicep template.
Azure DevOps Preparations
There are two requirements to make sure the Bicep template can be successfully orchestrated and that the service connections can be created in Azure DevOps:
- Inject Azure DevOps
System.AccessTokenfrom the pipeline
The System.AccessToken is a special variable that carries the “security token” used by the running build. This security token is coupled with the built-in Azure DevOps build service. The name of the build service is as follows: <project name> Build Service and is a user. Each run, the System.AccessToken is disposed and refreshed when a new run is started.
To use the System.AccessToken, it is required to explicitly map it into the Azure pipeline using a variable. Below, you see the pipeline that is used for the orchestration of the Bicep template:
| steps: | |
| - task: AzureCLI@2 | |
| inputs: | |
| azureSubscription: '<service connection here>' | |
| scriptType: 'pscore' | |
| scriptLocation: 'inlineScript' | |
| inlineScript: "az deployment group create --template-file <bicep template> --parameters <bicep parameter file> --resource-group <resource group>" | |
| env: | |
| SYSTEM_ACCESSTOKEN: $(System.AccessToken) |
The System.AccessToken is now loaded as an environment variable. To use the token in your Bicep parameter file, you need to make use of the readEnvironmentVariable() function:
| param parAzureDevOpsSystemAccessToken = readEnvironmentVariable('SYSTEM_ACCESSTOKEN') |
- Azure DevOps permissions
Given that the Azure DevOps build service “user” security token will be used, it’s necessary to configure the Endpoint Administrators permission within the Azure DevOps project(s). This permission allows the build service to administer the service connection(s). Additionally, this permission enables the build service to check Grant access permission to all pipelines to enabled.
Deep dive Bicep template
Now that the pre-required steps are clear, let’s take a look at the outline of the Bicep templates and what it deploys to Azure (in consecutive order / dependencies):
- Deployment of a deployment script aimed to retrieve Azure DevOps information such as organisation
instanceIdto build theissuerstring and it queries the project for details such as organisation name, project name and service connection name to build thesubjectIdentifierstring. The issuer and subjectIdentifier are outputs and used as input in the managed identity.
Bicep template reference –symbolic name: resIssuerAndIdentifierIds - Deployment of a user-assigned managed identity with federated identity credentials. This managed identity will be used as the service connection. The federated identity credentials are filled with the
issuerandsubjectIdentifieroutputs from the deployment script (from step 1).
Bicep template reference –symbolic name: resUserAssignedManagedIdentityServiceConnection - Deployment of a role assignment. The managed identity created in step 2 is assigned the contributor role. Contributor is not a requirement to “verify” your service connection. The Reader role would be enough to verify the connection.
Bicep template reference –symbolic name: modRoleAssignment - At last, the deployment of a deployment script which creates the Azure DevOps service connection using workload identity federations. When a service connection already exists it will remove the connection and recreates it. This won’t affect your Azure Pipelines.
Bicep template reference –symbolic name: resCreateOIDCServiceConnection
Let’s take a look at the Bicep template:
| @sys.description('The access token used to manage Azure DevOps') | |
| @secure() | |
| param parAzureDevOpsSystemAccessToken string | |
| @sys.description('The name of the Azure DevOps organisation') | |
| param parAzureDevOpsOrganisationName string | |
| @sys.description('The name of the Azure DevOps project where the service connection will be created') | |
| param parAzureDevOpsProjectName string | |
| @sys.description('The name of the Azure DevOps service connection') | |
| param parAzureDevOpsServiceConnectionName string | |
| @sys.description('The location of the resources') | |
| param parLocation string = 'westeurope' | |
| @description('Generated. Used to make sure the script run every time the template is deployed.') | |
| param baseTime string = utcNow('yyyy-MM-dd-HH-mm-ss') | |
| resource resIssuerAndIdentifierIds 'Microsoft.Resources/deploymentScripts@2020-10-01' = { | |
| name: 'ds-gather-ado-info' | |
| location: parLocation | |
| kind: 'AzurePowerShell' | |
| properties: { | |
| azPowerShellVersion: '11.0' | |
| retentionInterval: 'P1D' | |
| forceUpdateTag: baseTime | |
| environmentVariables: [ | |
| { | |
| name: 'SystemAccessToken' | |
| secureValue: parAzureDevOpsSystemAccessToken | |
| } | |
| ] | |
| arguments: '-AzureDevOpsOrganisationName ${parAzureDevOpsOrganisationName} -AzureDevOpsProjectName ${parAzureDevOpsProjectName} -AzureDevOpsServiceConnectionName ${parAzureDevOpsServiceConnectionName}' | |
| scriptContent: ''' | |
| param ( | |
| [Parameter(Mandatory=$true)] | |
| [string] $AzureDevOpsOrganisationName, | |
| [Parameter(Mandatory=$true)] | |
| [string] $AzureDevOpsProjectName, | |
| [Parameter(Mandatory=$true)] | |
| [string] $AzureDevOpsServiceConnectionName | |
| ) | |
| $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$(${Env:SystemAccessToken})")) | |
| $header = @{ | |
| Authorization = "Basic $base64AuthInfo" | |
| } | |
| # Retrieving Azure DevOps Organisation ID | |
| $restApiAdoOrgInfo = "https://dev.azure.com/$AzureDevOpsOrganisationName/_apis/connectiondata?api-version=5.0-preview.1" | |
| $azureDevOpsOrganisationId = Invoke-RestMethod -Uri $restApiAdoOrgInfo -Headers $header -Method Get | Select-Object -ExpandProperty instanceId | |
| # Retrieve Azure DevOps Project ID | |
| $restApiAdoProjectInfo = "https://dev.azure.com/$AzureDevOpsOrganisationName/_apis/projects/$($AzureDevOpsProjectName)?api-version=7.1-preview.4" | |
| $azureDevOpsProjectId = Invoke-RestMethod -Uri $restApiAdoProjectInfo -Headers $header -Method Get | Select-Object -ExpandProperty id | |
| # OIDC information needed for User Assigned Managed Identity | |
| $issuer = "https://vstoken.dev.azure.com/$azureDevOpsOrganisationId" | |
| $subjectIdentifier = "sc://$AzureDevOpsOrganisationName/$AzureDevOpsProjectName/$AzureDevOpsServiceConnectionName" | |
| $deploymentScriptOutputs = @{} | |
| $deploymentScriptOutputs["issuer"] = $issuer | |
| $deploymentScriptOutputs["subjectIdentifier"] = $subjectIdentifier | |
| ''' | |
| } | |
| } | |
| resource resUserAssignedManagedIdentityServiceConnection 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { | |
| name: parAzureDevOpsServiceConnectionName | |
| location: parLocation | |
| dependsOn: [ | |
| resIssuerAndIdentifierIds | |
| ] | |
| resource resFederation 'federatedIdentityCredentials' = { | |
| name: 'federatedIdentityCredentialsAzureDevOps' | |
| properties: { | |
| issuer: resIssuerAndIdentifierIds.properties.outputs.issuer | |
| subject: resIssuerAndIdentifierIds.properties.outputs.subjectIdentifier | |
| audiences: [ | |
| 'api://AzureADTokenExchange' | |
| ] | |
| } | |
| } | |
| } | |
| module modRoleAssignment 'roleAssignment.bicep' = { | |
| name: 'deploy-set-uami-contributor' | |
| scope: subscription() | |
| params: { | |
| resUserAssignedManagedIdentityServiceConnectionPrincipalId: resUserAssignedManagedIdentityServiceConnection.properties.principalId | |
| resUserAssignedManagedIdentityServiceConnectionResourceId: resUserAssignedManagedIdentityServiceConnection.id | |
| } | |
| } | |
| resource resCreateOIDCServiceConnection 'Microsoft.Resources/deploymentScripts@2020-10-01' = { | |
| name: 'ds-configure-oidc-ado-service-connection' | |
| location: parLocation | |
| kind: 'AzurePowerShell' | |
| properties: { | |
| azPowerShellVersion: '11.0' | |
| retentionInterval: 'P1D' | |
| forceUpdateTag: baseTime | |
| environmentVariables: [ | |
| { | |
| name: 'SystemAccessToken' | |
| secureValue: parAzureDevOpsSystemAccessToken | |
| } | |
| ] | |
| arguments: '-AzureDevOpsOrganisationName ${parAzureDevOpsOrganisationName} -AzureDevOpsProjectName ${parAzureDevOpsProjectName} -AzureDevOpsServiceConnectionName ${parAzureDevOpsServiceConnectionName} -TenantId ${tenant().tenantId} -SubscriptionId ${subscription().subscriptionId} -SubscriptionName \'${subscription().displayName}\' -UserAssignedManagedIdentityClientId ${resUserAssignedManagedIdentityServiceConnection.properties.clientId}' | |
| scriptContent: ''' | |
| param ( | |
| [Parameter(Mandatory=$true)] | |
| [string] $AzureDevOpsOrganisationName, | |
| [Parameter(Mandatory=$true)] | |
| [string] $AzureDevOpsProjectName, | |
| [Parameter(Mandatory=$true)] | |
| [string] $AzureDevOpsServiceConnectionName, | |
| [Parameter(Mandatory=$true)] | |
| [string] $TenantId, | |
| [Parameter(Mandatory=$true)] | |
| [string] $SubscriptionId, | |
| [Parameter(Mandatory=$true)] | |
| [string] $SubscriptionName, | |
| [Parameter(Mandatory=$true)] | |
| [string] $UserAssignedManagedIdentityClientId | |
| ) | |
| $base64AuthInfo = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$(${Env:SystemAccessToken})")) | |
| $header = @{ | |
| Authorization = "Basic $base64AuthInfo" | |
| } | |
| # Retrieve Azure DevOps Project ID | |
| $restApiAdoProjectInfo = "https://dev.azure.com/$AzureDevOpsOrganisationName/_apis/projects/$($AzureDevOpsProjectName)?api-version=7.1-preview.4" | |
| $azureDevOpsProjectId = Invoke-RestMethod -Uri $restApiAdoProjectInfo -Headers $header -Method Get | Select-Object -ExpandProperty id | |
| $body = @" | |
| { | |
| "authorization": { | |
| "parameters": { | |
| "serviceprincipalid": "$UserAssignedManagedIdentityClientId", | |
| "tenantid": "$TenantId" | |
| }, | |
| "scheme": "WorkloadIdentityFederation" | |
| }, | |
| "createdBy": {}, | |
| "data": { | |
| "environment": "AzureCloud", | |
| "scopeLevel": "Subscription", | |
| "creationMode": "Manual", | |
| "subscriptionId": "$SubscriptionId", | |
| "subscriptionName": "$SubscriptionName" | |
| }, | |
| "isShared": false, | |
| "isOutdated": false, | |
| "isReady": false, | |
| "name": "$AzureDevOpsServiceConnectionName", | |
| "owner": "library", | |
| "type": "AzureRM", | |
| "url": "https://management.azure.com/", | |
| "description": "", | |
| "serviceEndpointProjectReferences": [ | |
| { | |
| "description": "", | |
| "name": "$AzureDevOpsServiceConnectionName", | |
| "projectReference": { | |
| "id": "$azureDevOpsProjectId", | |
| "name": "$AzureDevOpsProjectName" | |
| } | |
| } | |
| ] | |
| } | |
| "@ | |
| # Check if service connection exists, if yes then delete existing object | |
| $getServiceConnectionEndpointUrl = "https://dev.azure.com/$AzureDevOpsOrganisationName/$AzureDevOpsProjectName/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4" | |
| $existing = Invoke-RestMethod -Uri $getServiceConnectionEndpointUrl -Headers $header -Method Get | Select-Object -ExpandProperty value | Where-Object { $_.name -eq $AzureDevOpsServiceConnectionName } | Select-Object -ExpandProperty id | |
| if ($existing) { | |
| $deleteServiceConnectionEndpointUrl = "https://dev.azure.com/$AzureDevOpsOrganisationName/_apis/serviceendpoint/endpoints/$($existing)?projectIds=$azureDevOpsProjectId&api-version=7.1-preview.4" | |
| Invoke-RestMethod -Uri $deleteServiceConnectionEndpointUrl -Headers $header -Method Delete | |
| } | |
| # Registering OIDC service connection | |
| $restApiEndpointUrl = "https://dev.azure.com/$AzureDevOpsOrganisationName/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4" | |
| $serviceConnection = Invoke-RestMethod -Uri $restApiEndpointUrl -Headers $header -Method Post -Body $body -ContentType "application/json" | |
| # Set Pipeline permissions | |
| $permissionBody = @" | |
| { | |
| "allPipelines": { | |
| "authorizedBy": null, | |
| "authorizedOn": null, | |
| "authorized": true | |
| }, | |
| "pipelines": [], | |
| "resource": { | |
| "type": "endpoint", | |
| "id": "$($serviceConnection | Select-Object -ExpandProperty id)" | |
| } | |
| } | |
| "@ | |
| $pipelinePermissionUri = "https://dev.azure.com/$AzureDevOpsOrganisationName/$AzureDevOpsProjectName/_apis/pipelines/pipelinePermissions/endpoint/$($serviceConnection | Select-Object -ExpandProperty id)?api-version=7.1-preview.1" | |
| $pipelinePermissions = Invoke-RestMethod -Uri $pipelinePermissionUri -Headers $header -Method Patch -Body $permissionBody -ContentType "application/json" | |
| return $serviceConnection | |
| ''' | |
| } | |
| } |
Role assignment module:
| targetScope = 'subscription' | |
| param resUserAssignedManagedIdentityServiceConnectionResourceId string | |
| param resUserAssignedManagedIdentityServiceConnectionPrincipalId string | |
| resource resContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-05-01-preview' existing = { | |
| name: 'b24988ac-6180-42a0-ab88-20f7382dd24c' // Contributor | |
| scope: subscription() | |
| } | |
| resource modRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { | |
| name: guid(subscription().id, resContributorRoleDefinition.id, resUserAssignedManagedIdentityServiceConnectionResourceId) | |
| scope: subscription() | |
| properties: { | |
| principalId: resUserAssignedManagedIdentityServiceConnectionPrincipalId | |
| roleDefinitionId: resContributorRoleDefinition.id | |
| principalType: 'ServicePrincipal' | |
| } | |
| } |
Each deployment script uses the secure parameter parAzureDevOpsSystemAccessToken which is filled with System.AccessToken retrieved as an environment variable from the Azure pipeline. This token is loaded in the deployment script resource securely so it is not visible in the deployment as seen in the image below:

Output
After the deployment is done the following resources are created:

The managed identity from the resources in the image above is used for Azure DevOps service connection:

Enhancements
The use case above can be limited to what you are looking for. Below are some potential enhancements that can be applied to the above solution:
- Being able to specify a subscription instead of using the deployment context subscription
In the template above the role assignment is scoped to the subscription where the deployment is done. If you want to scope to another subscription add parameters parSubscriptionId and parSubscriptionName and apply these to the input parameter of the deployment script resCreateOIDCServiceConnection like this: -SubscriptionId parSubscriptionId -SubscriptionName \'${parSubscriptionName}\'.
Also, update the scope: subscription(parSubscriptionId) in the role assignment module modRoleAssignment
- Being able to create and configure service connections in other Azure DevOps projects (within the organisation)
In the preparation you have seen that the Azure DevOps build service is given Endpoint Administrators permission. This service is bound to a project, but if you want to go outside of the project you can add the build service to the Endpoint Administrators permission of the other Azure DevOps project. Also, the build service must be able to read the project information because it has to build the subjectIdentifier. The build service must also have Reader permissions in the other project(s).
Note! The build service is given cross-project permissions to service connections, so be aware of the security implications it can have. Each pipeline within the origin project can use the System.AccessToken and can modify service connections where it has permission to.
Conclusion
This is how you can implement and configure Azure DevOps service connections that are using workload identity federation with OpenID Connect, deployed using Azure Bicep.
In this blog, you have seen what specific permissions the build service needs within Azure DevOps. Furthermore, you have seen insights into what the Bicep template deploys to Azure. And lastly, what kind of enhancements can be made to improve the Bicep template with expanded features.
The above Bicep template can be incorporated as a module within new or existing infrastructure as code. If you are looking for a Terraform solution, Microsoft has created a blog post about this: Introduction to Azure DevOps Workload Identity Federation (OIDC) with Terraform.