Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure User-Assigned Managed Identity: stable client/principal IDs, federated credentials for Workload Identity, and clean RBAC outputs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "managed_identity" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-identity?ref=v1.0.0"
name = "..." # Name of the identity (3-128 chars, must not end with a …
resource_group_name = "..." # Resource group that holds the identity.
location = "..." # Azure region for the identity.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A User-Assigned Managed Identity (UAMI) is a standalone Azure AD (Microsoft Entra ID) identity that lives as its own ARM resource, independent of any compute. Unlike a system-assigned identity — which is born and dies with a single VM, App Service, or container — a user-assigned identity has its own lifecycle. You create it once, then attach the same identity to many resources (a VMSS, a Function App, an AKS pod, a Container App) and grant it RBAC roles a single time. The workloads it backs authenticate to Azure services with no client secrets, no connection strings, and no certificates to rotate.
Wrapping it in a reusable module matters because a UAMI is rarely used alone. In production you almost always pair it with:
- Role assignments — the identity is useless until it can do something (read a Key Vault secret, pull from ACR, write to a Storage account). The module wires these in one place so the identity and its permissions ship together.
- Federated identity credentials — the modern way to let GitHub Actions, Azure DevOps, or a Kubernetes service account (AKS Workload Identity) exchange an OIDC token for an Azure AD token without ever creating an
azurerm_user_assigned_identitysecret. This is the keystone of passwordless CI/CD and pod identity.
The module exposes the three IDs every consumer needs — the resource id (for identity { identity_ids = [...] } blocks), the client_id (for DefaultAzureCredential and AZURE_CLIENT_ID), and the principal_id (for RBAC principal_id = and scope = assignments). Getting those three plumbed correctly is exactly the boilerplate this module removes.
When to use it
Reach for this module when:
- A workload must outlive or span its compute. Blue/green deployments, VMSS instances that scale in and out, or an App Service you redeploy frequently all keep the same identity — so you grant RBAC once and never touch it again.
- Multiple resources need the same permissions. Five Function Apps that all read the same Key Vault should share one identity, not maintain five separate system-assigned identities and twenty-five role assignments.
- You need passwordless CI/CD or Kubernetes Workload Identity. Federated credentials require a user-assigned identity — system-assigned cannot carry them.
- You want to grant RBAC before the compute exists. Because the identity’s
principal_idis stable and known at plan/apply time, you can assign roles in the same run that creates the consuming app, avoiding the chicken-and-egg dependency of system-assigned identities.
Prefer a system-assigned identity instead when the identity is truly 1:1 with a single short-lived resource and you want Azure to garbage-collect it automatically on delete.
Module structure
terraform-module-azure-managed-identity/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_user_assigned_identity + role assignments + federated creds
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, client_id, principal_id, name, federated credential ids
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Index role assignments by a stable key so adding/removing one role
# never forces re-creation of unrelated assignments.
role_assignments = {
for ra in var.role_assignments :
coalesce(ra.name, "${ra.role_definition_name}|${ra.scope}") => ra
}
federated_credentials = {
for fc in var.federated_credentials : fc.name => fc
}
}
resource "azurerm_user_assigned_identity" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
tags = var.tags
}
# Grant the identity its permissions in the same module so identity and
# access ship together. role_definition_name is mutually exclusive with id.
resource "azurerm_role_assignment" "this" {
for_each = local.role_assignments
scope = each.value.scope
principal_id = azurerm_user_assigned_identity.this.principal_id
principal_type = "ServicePrincipal"
role_definition_name = each.value.role_definition_id == null ? each.value.role_definition_name : null
role_definition_id = each.value.role_definition_id
condition = each.value.condition
condition_version = each.value.condition == null ? null : "2.0"
skip_service_principal_aad_check = true
}
# Federated identity credentials enable passwordless OIDC trust for
# GitHub Actions, Azure DevOps, or AKS Workload Identity — no secrets.
resource "azurerm_federated_identity_credential" "this" {
for_each = local.federated_credentials
name = each.value.name
resource_group_name = var.resource_group_name
parent_id = azurerm_user_assigned_identity.this.id
audience = each.value.audience
issuer = each.value.issuer
subject = each.value.subject
}
skip_service_principal_aad_check = trueavoids a race where Terraform tries to assign a role before the freshly created service principal has replicated through Azure AD — a classic intermittentPrincipalNotFoundfailure on first apply.
variables.tf
variable "name" {
description = "Name of the user-assigned managed identity. Often prefixed with 'id-' per CAF naming."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9._()-]{3,128}$", var.name)) && !can(regex("[.]$", var.name))
error_message = "Name must be 3-128 chars (letters, digits, '.', '_', '(', ')', '-') and must not end with a period."
}
}
variable "resource_group_name" {
description = "Name of the resource group that will hold the identity."
type = string
}
variable "location" {
description = "Azure region for the identity (e.g. 'centralindia', 'eastus')."
type = string
}
variable "tags" {
description = "Tags to apply to the identity."
type = map(string)
default = {}
}
variable "role_assignments" {
description = <<-EOT
Azure RBAC role assignments granted to this identity. Provide either
role_definition_name (e.g. "Key Vault Secrets User") OR role_definition_id,
not both. scope is the target resource/RG/subscription ID.
EOT
type = list(object({
name = optional(string)
scope = string
role_definition_name = optional(string)
role_definition_id = optional(string)
condition = optional(string)
}))
default = []
validation {
condition = alltrue([
for ra in var.role_assignments :
(ra.role_definition_name == null) != (ra.role_definition_id == null)
])
error_message = "Each role assignment must set exactly one of role_definition_name or role_definition_id."
}
validation {
condition = alltrue([
for ra in var.role_assignments : can(regex("^/subscriptions/", ra.scope))
])
error_message = "Each role assignment scope must be a full ARM resource ID starting with /subscriptions/."
}
}
variable "federated_credentials" {
description = <<-EOT
Federated identity credentials (OIDC) for Workload Identity / passwordless CI.
'issuer' is the OIDC issuer URL, 'subject' is the token subject (e.g.
'repo:org/repo:ref:refs/heads/main' or 'system:serviceaccount:ns:sa').
EOT
type = list(object({
name = string
issuer = string
subject = string
audience = optional(list(string), ["api://AzureADTokenExchange"])
}))
default = []
validation {
condition = alltrue([
for fc in var.federated_credentials :
can(regex("^https://", fc.issuer))
])
error_message = "Each federated credential issuer must be an HTTPS OIDC issuer URL."
}
}
outputs.tf
output "id" {
description = "Resource ID of the identity. Use in identity { identity_ids = [...] } blocks."
value = azurerm_user_assigned_identity.this.id
}
output "name" {
description = "Name of the user-assigned managed identity."
value = azurerm_user_assigned_identity.this.name
}
output "client_id" {
description = "Application (client) ID. Set as AZURE_CLIENT_ID for DefaultAzureCredential."
value = azurerm_user_assigned_identity.this.client_id
}
output "principal_id" {
description = "Object (principal) ID of the underlying service principal. Use for RBAC principal_id."
value = azurerm_user_assigned_identity.this.principal_id
}
output "tenant_id" {
description = "Tenant ID the identity belongs to."
value = azurerm_user_assigned_identity.this.tenant_id
}
output "role_assignment_ids" {
description = "Map of role-assignment key => role assignment resource ID."
value = { for k, ra in azurerm_role_assignment.this : k => ra.id }
}
output "federated_credential_ids" {
description = "Map of federated credential name => resource ID."
value = { for k, fc in azurerm_federated_identity_credential.this : k => fc.id }
}
How to use it
The example below creates an identity for a Function App, grants it Key Vault Secrets User on a vault and AcrPull on a registry, and adds a GitHub Actions federated credential so the same identity backs passwordless deploys. The downstream Function App consumes the identity’s id and surfaces its client_id as an app setting.
module "user_assigned_managed_identity" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-identity?ref=v1.0.0"
name = "id-orders-api-prod"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
role_assignments = [
{
scope = azurerm_key_vault.app.id
role_definition_name = "Key Vault Secrets User"
},
{
scope = azurerm_container_registry.app.id
role_definition_name = "AcrPull"
},
]
federated_credentials = [
{
name = "github-orders-api-main"
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:teknohut/orders-api:ref:refs/heads/main"
},
]
tags = {
env = "prod"
workload = "orders-api"
managedBy = "terraform"
}
}
# Downstream: attach the identity to the Function App and expose its client_id.
resource "azurerm_linux_function_app" "orders" {
name = "func-orders-api-prod"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
service_plan_id = azurerm_service_plan.app.id
storage_account_name = azurerm_storage_account.app.name
storage_uses_managed_identity = true
identity {
type = "UserAssigned"
identity_ids = [module.user_assigned_managed_identity.id]
}
app_settings = {
# DefaultAzureCredential picks this up to select the right UAMI.
AZURE_CLIENT_ID = module.user_assigned_managed_identity.client_id
}
site_config {}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/managed_identity/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-identity?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/managed_identity && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the identity (3-128 chars, must not end with a period). |
resource_group_name |
string |
— | Yes | Resource group that holds the identity. |
location |
string |
— | Yes | Azure region for the identity. |
tags |
map(string) |
{} |
No | Tags applied to the identity. |
role_assignments |
list(object({...})) |
[] |
No | RBAC roles to grant the identity; each item sets scope plus exactly one of role_definition_name or role_definition_id (optional name, condition). |
federated_credentials |
list(object({...})) |
[] |
No | OIDC federated credentials (Workload Identity / passwordless CI); each sets name, issuer, subject, and optional audience. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the identity; use in identity { identity_ids = [...] } blocks. |
name |
Name of the user-assigned managed identity. |
client_id |
Application (client) ID; set as AZURE_CLIENT_ID for DefaultAzureCredential. |
principal_id |
Object (principal) ID of the service principal; use for RBAC principal_id. |
tenant_id |
Tenant ID the identity belongs to. |
role_assignment_ids |
Map of role-assignment key to role assignment resource ID. |
federated_credential_ids |
Map of federated credential name to resource ID. |
Enterprise scenario
A retail platform team runs an orders-api across blue/green slots on Azure Container Apps, with deploys driven from GitHub Actions. They provision a single id-orders-api-prod UAMI with this module: it holds AcrPull on the shared registry and Key Vault Secrets User on the per-environment vault, and carries a federated credential trusting repo:teknohut/orders-api:ref:refs/heads/main. The GitHub workflow logs in with OIDC — no client secret stored in Actions — and every container revision attaches the same identity, so RBAC is granted exactly once and survives the dozens of revisions Container Apps creates each week.
Best practices
- Grant least privilege at the narrowest scope. Assign roles on the specific resource (a single Key Vault or Storage account), not the resource group or subscription. Prefer fine-grained built-in roles like Key Vault Secrets User over broad ones like Contributor or the deprecated Key Vault Reader + access-policy combo.
- Use federated credentials instead of secrets — always. A UAMI with federated credentials needs no secret to rotate or leak. Never pair a managed identity with a manually created service-principal secret; that defeats the entire purpose.
- Pin the federated
subjecttightly. Scope GitHub credentials to a specific branch or environment (ref:refs/heads/mainorenvironment:prod), and AKS credentials to an exactsystem:serviceaccount:<ns>:<sa>. A wildcard-ish subject is an open door into your Azure RBAC. - One identity per workload, not per resource. Share a single identity across the instances of one workload (all revisions/slots), but do not reuse the same identity across unrelated workloads — blast radius and audit clarity both suffer when permissions are commingled.
- Follow CAF naming and tag for ownership. Prefix with
id-(e.g.id-orders-api-prod) and tagworkload,env, andmanagedByso identities are traceable in Azure AD’s long, flat list of service principals. - Cost is effectively zero, but sprawl is not. UAMIs are free, yet orphaned identities and stale role assignments accumulate as security debt. Manage them in Terraform so
terraform destroyremoves both the identity and its role assignments cleanly, leaving no dangling RBAC.