Terraform - Implementing Azure DevOps Workload Identity Federation End-to-End
Hello everyone! I want to share how I replaced expiring service connection secrets with a better solution: Workload Identity Federation (WIF). Instead of manually renewing secrets, I built an automated approach using Terraform to manage service connections in a more secure and scalable way.
What is Workload Identity Federation? #
Workload Identity Federation lets Azure DevOps pipelines authenticate to Azure without storing secrets.It eliminates the need for client secrets or certificates by leveraging short-lived tokens issued at runtime.
How it works #
The authentication happens through 4 simple steps:
-
Trust configuration You create a federated credential in Microsoft Entra ID that trusts a specific Azure DevOps service connection (based on issuer and subject).
-
Token issuance When the pipeline runs, Azure DevOps generates a short-lived token for the service connection.
-
Token validation Microsoft Entra ID validates the token using OpenID Connect (OIDC), verifying that it matches the configured issuer and subject.
-
Access granted The token is accepted, and the pipeline authenticates as the managed identity, gaining access to Azure based on the assigned role.
Token Basics #
The tokens issued by Azure DevOps follow the OIDC standard, and include:
- Issuer: who created the token.
- Subject: who the token is for.
- Audience: who will accept the token.
Microsoft Entra ID checks these tokens and grants access, enabling secure, secretless deployments from your DevOps pipelines.
Let’s get started!
Prerequisites #
- You need Terraform CLI on your local machine, if you’re new to using Terraform to deploy Microsoft Azure resources, then I recommend you check out this link.
- A text editor or IDE of your choice (Visual Studio Code with terraform extension is my recommendation)
Declare Azure Provider in Terraform #
The provider.tf file in Terraform is used to specify and configure the providers used in your Terraform configuration. A provider is a service or platform where the resources will be managed. This could be a cloud provider like Microsoft Azure, AWS, Google Cloud, etc.
This file is important because it tells Terraform which provider’s API to use when creating, updating, and deleting resources. Without it, Terraform wouldn’t know where to manage your resources.
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
Important: Terraform now requires an explicit subscription ID in the provider configuration for clearer, more predictable, and secure infrastructure management in complex cloud environments.
Deploy Azure Resources Using Terraform #
In the case of the Workload Identity Federation deployment for Azure DevOps, the main.tf file contains the following key components:
data blocks: These are used to retrieve information about existing Azure resource groups and Azure DevOps projects (when create_project is set to false). This allows the module to integrate with existing infrastructure without requiring full recreation.
azurerm_user_assigned_identity: This block creates a User Assigned Managed Identity in the specified Azure resource group and region. Each identity is uniquely named according to the input variables.
azuredevops_project: Conditionally creates an Azure DevOps project if specified in the input (create_project = true). Otherwise, the configuration falls back to a data source to reference an existing project.
azuredevops_serviceendpoint_azurerm: This block creates a federated service connection in Azure DevOps. It links the DevOps project to the Azure subscription using the managed identity, and sets up the federation trust using OIDC.
azurerm_federated_identity_credential: Defines the federated credentials within the User Assigned Managed Identity. These credentials establish the trust with Azure DevOps and specify the issuer and subject needed for token federation.
azurerm_role_assignment: Assigns the appropriate role (e.g., Contributor or Reader) to the managed identity over the specified Azure resource group. This ensures the identity has the necessary permissions when used in DevOps pipelines.
data "azurerm_resource_group" "rg" {
for_each = { for i in var.federated_identities : i.name => i }
name = each.value.resource_group_name
}
resource "azurerm_user_assigned_identity" "federated" {
for_each = { for i in var.federated_identities : i.name => i }
name = each.value.name
location = each.value.location
resource_group_name = each.value.resource_group_name
}
resource "azuredevops_project" "projects" {
for_each = { for i in var.federated_identities : i.name => i if i.create_project }
name = each.value.devops_project_name
visibility = "private"
version_control = "Git"
work_item_template = "Agile"
description = "Managed by Terraform"
}
data "azuredevops_project" "existing_projects" {
for_each = { for i in var.federated_identities : i.name => i if !i.create_project }
name = each.value.devops_project_name
}
resource "azuredevops_serviceendpoint_azurerm" "connections" {
for_each = { for i in var.federated_identities : i.service_connection => i }
project_id = try(
azuredevops_project.projects[each.value.name].id,
data.azuredevops_project.existing_projects[each.value.name].id
)
service_endpoint_name = each.value.service_connection
description = "Managed by Terraform"
service_endpoint_authentication_scheme = "WorkloadIdentityFederation"
credentials {
serviceprincipalid = azurerm_user_assigned_identity.federated[each.value.name].client_id
}
azurerm_spn_tenantid = each.value.tenant_id
azurerm_subscription_id = each.value.subscription_id
azurerm_subscription_name = each.value.subscription_name
}
resource "azurerm_federated_identity_credential" "creds" {
for_each = { for i in var.federated_identities : i.name => i }
name = "fid-${each.value.name}"
resource_group_name = each.value.resource_group_name
parent_id = azurerm_user_assigned_identity.federated[each.value.name].id
audience = ["api://AzureADTokenExchange"]
issuer = azuredevops_serviceendpoint_azurerm.connections[each.value.service_connection].workload_identity_federation_issuer
subject = "sc://${each.value.org_name}/${each.value.devops_project_name}/${each.value.service_connection}"
}
resource "azurerm_role_assignment" "identity_rg_access" {
for_each = { for i in var.federated_identities : i.name => i }
scope = data.azurerm_resource_group.rg[each.key].id
role_definition_name = each.value.role_definition_name
principal_id = azurerm_user_assigned_identity.federated[each.key].principal_id
depends_on = [
azurerm_user_assigned_identity.federated
]
}
Declaration of input variables #
The variables.tf file in Terraform defines the variables I will use in the main.tf file. These variables allow for more flexibility and reusability in the code.
In this example, the variables defined in the variables.tf file include:
subscription_id: The ID of the Azure subscription where the resources (such as identities and role assignments) will be deployed.
azure_devops_org_url: The URL of your Azure DevOps organization, typically in the format https://dev.azure.com/your-org-name.
azure_devops_pat: The Personal Access Token (PAT) used to authenticate with Azure DevOps. This token should have permission to manage service connections and projects.
federated_identities: This is a list of objects, where each object contains the configuration for a federated identity setup. Each entry includes:
- name: A unique name for the managed identity.
- location: Azure region where the identity will be created.
- resource_group_name: Name of the resource group that will contain the identity.
- devops_project_name: Name of the Azure DevOps project associated with the service connection.
- service_connection: Name of the service connection to be created in Azure DevOps.
- tenant_id: The Microsoft Entra ID tenant ID where the managed identity and federated credentials will be authenticated.
- subscription_id: The Azure subscription ID to which the service connection will grant access.
- subscription_name: A display name for the Azure subscription, shown in the Azure DevOps portal under Service Connections.
- org_name: The Azure DevOps organization name used to construct the federated credential subject.
- role_definition_name: The role assigned to the managed identity (e.g., Contributor, Reader).
- create_project: A boolean that defines whether the Azure DevOps project should be created (true) or if it already exists (false).
These variables allow the module to dynamically handle multiple federated identity configurations, making it suitable for enterprise-scale deployments.
// Azure Subscription ID where resources will be deployed
variable "subscription_id" {
type = string
description = "Azure subscription where resources will be deployed."
sensitive = true
}
// Azure DevOps organization URL
variable "azure_devops_org_url" {
type = string
description = "URL of the Azure DevOps organization"
}
// Azure DevOps Personal Access Token used for authentication
variable "azure_devops_pat" {
type = string
description = "Personal Access Token for Azure DevOps authentication."
sensitive = true
}
// Federated identity configurations for linking Azure DevOps and Azure AD
variable "federated_identities" {
description = "List of federated identity configurations for Azure DevOps service connections."
type = list(object({
name = string
location = string
resource_group_name = string
devops_project_name = string
service_connection = string
tenant_id = string
subscription_id = string
subscription_name = string
org_name = string
role_definition_name = string
create_project = bool
}))
}
Declaration of output values #
The output.tf file in Terraform extracts and displays information about the resources created or managed by your Terraform configuration. These outputs are defined using the output
keyword and can be used to return information that might be useful for the user, for other Terraform configurations, or for programmatically using the information in scripts or other tools.
In this example, the output.tf file provides the names and IDs of both the Azure DevOps service connections and the federated identity credentials created in Azure Active Directory.
Once Terraform has finished applying your configuration, it will display the defined outputs.
// List of created Azure DevOps service connection names
output "service_connections" {
description = "List of created Azure DevOps service connections."
value = [
for sc in azuredevops_serviceendpoint_azurerm.connections :
{
id = sc.id
name = sc.service_endpoint_name
}
]
}
// List of created federated identity credential names
output "federated_identity_credentials" {
description = "List of created federated identity credentials in Azure AD."
value = [
for fid in azurerm_federated_identity_credential.creds :
{
id = fid.id
name = fid.name
}
]
}
Executing the Terraform Deployment #
Now that you’ve declared the resources correctly, it’s time to take the following steps to deploy them in your Azure environment.
-
Initialization: To begin, execute the terraform init command. This will initialize your working directory that holds the .tf files and download the provider specified in the provider.tf file, and configure the Terraform backend. If you want to know how, check this link.
-
Planning: Next, execute the terraform plan. This command creates an execution plan and shows Terraform’s actions to achieve the desired state defined in your .tf files. This gives you a chance to review the changes before applying them.
-
Apply: When you’re satisfied with the plan, execute the terraform apply command. This will implement the required modifications to attain the intended infrastructure state. Before making any changes, you will be asked to confirm your decision.
-
Inspection: After applying the changes, you can use terraform show command to see the current state of your infrastructure.
-
Destroy (optional): when a project is no longer needed or when resources have become outdated. You can use the terraform destroy command. This will remove all the resources that Terraform has created.
References and useful links #
Thank you for taking the time to read my post. I sincerely hope that you find it helpful.