IaC Azure

Terraform Module: Azure User-Assigned Managed Identity — one identity, many resources, zero secrets

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:

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:

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 = true avoids a race where Terraform tries to assign a role before the freshly created service principal has replicated through Azure AD — a classic intermittent PrincipalNotFound failure 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 configlive/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 configlive/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

TerraformAzureUser-Assigned Managed IdentityModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading