IaC Azure

Terraform Module: Azure Key Vault Managed HSM — FIPS 140-2 Level 3 Keys With a Governed, Reviewed Activation Path

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_key_vault_managed_hardware_security_module: purge protection, network ACLs, security-domain activation, RBAC role assignments/definitions, and optional HSM-backed keys — production defaults baked in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works, the mandatory activation step, 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 {}
}

data "azurerm_client_config" "current" {}

module "managed_hsm" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-hsm?ref=v1.0.0"

  name                = "..."                                       # Globally-unique HSM name (3-24 chars, alphanumeric/hyphen).
  resource_group_name = "..."                                       # Resource group that will hold the HSM.
  location            = "..."                                       # Azure region with Managed HSM availability.
  tenant_id           = data.azurerm_client_config.current.tenant_id
  admin_object_ids    = ["..."]                                     # Entra ID object IDs of HSM administrators (>=1).

  # Activation is a SEPARATE, out-of-band step (see "What this module is").
  # Provide >=3 Key Vault certificate IDs + a quorum once you are ready to
  # activate; until then the HSM provisions but cannot mint keys.
  # security_domain_certificate_ids = ["...", "...", "..."]
  # security_domain_quorum          = 2
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Key Vault Managed HSM (azurerm_key_vault_managed_hardware_security_module) is a fully managed, single-tenant, FIPS 140-2 Level 3 validated hardware security module pool. Unlike the standard Key Vault, where Microsoft shares the HSM backend across tenants, a Managed HSM gives you a dedicated cluster of HSM partitions, a private root of trust you control via a security domain, and a pure-RBAC data-plane permission model (no access policies). It is the home for the keys that must not leave certified hardware: customer-managed keys for storage and SQL TDE, payment HSM operations, code-signing keys, and the top of a BYOK/HYOK key hierarchy.

The resource is small in argument count but carries two unusually sharp edges that this module exists to tame:

In the azurerm ~> 4.0 provider, activation is modelled through optional attributes on the resource itself: you pass security_domain_key_vault_certificate_ids (the Key Vault certificate IDs) and security_domain_quorum, and Terraform performs the activation as part of the apply. Leaving those out provisions the cluster but does not activate it — which is exactly right for environments where the activation ceremony is performed out-of-band by a separate quorum of security officers, sometimes via Azure CLI, with the certificate private keys held on offline media. This module exposes both paths: provision-only by default, or Terraform-driven activation when you supply the certificate IDs and quorum.

Wrapping all of this in a module encodes the correct posture once — purge protection on, soft-delete retention pinned, network ACLs defaulting to deny, public network access controllable, RBAC role assignments and custom role definitions driven by maps, and optional HSM-backed keys created only after activation — so teams inherit a Managed HSM that will pass a key-management audit instead of hand-rolling a forty-line resource and forgetting the activation story.

When to use it

Reach for the standard Azure Key Vault (azurerm_key_vault with sku_name = "premium") instead when shared-HSM (FIPS 140-2 Level 2/3 single-key) protection is enough and you do not need a dedicated pool, a security domain you own, or pure-RBAC isolation — it is far cheaper and has no activation ceremony.

Module structure

terraform-module-azure-managed-hsm/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # HSM, activation, role definitions/assignments, optional keys
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, hsm_uri, security-domain data, key ids

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  tags = merge(
    {
      managed_by = "terraform"
      module     = "azure-managed-hsm"
    },
    var.tags,
  )

  # Activation is performed in-band only when the caller supplies the security
  # domain certificate IDs. Otherwise the HSM is provisioned but left
  # un-activated for an out-of-band ceremony (see the article body).
  activate = length(var.security_domain_certificate_ids) > 0
}

resource "azurerm_key_vault_managed_hardware_security_module" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  # SKU is fixed by Azure for Managed HSM. Standard_B1 is the only value today.
  sku_name = var.sku_name

  tenant_id        = var.tenant_id
  admin_object_ids = var.admin_object_ids

  # Purge protection is REQUIRED for a production HSM: it prevents a
  # soft-deleted HSM (or its keys) from being permanently purged before the
  # retention window elapses. Changing this forces a new resource.
  purge_protection_enabled   = var.purge_protection_enabled
  soft_delete_retention_days = var.soft_delete_retention_days

  # Network posture: default to private + deny so the HSM is not reachable
  # until an explicit allow (ACL bypass / private endpoint) is in place.
  public_network_access_enabled = var.public_network_access_enabled

  network_acls {
    bypass         = var.network_acls.bypass
    default_action = var.network_acls.default_action
  }

  # In-band activation: supply >=3 Key Vault certificate IDs and a quorum to
  # have Terraform encrypt and activate the security domain during apply.
  # Leave security_domain_certificate_ids empty to provision without activating.
  security_domain_key_vault_certificate_ids = local.activate ? var.security_domain_certificate_ids : null
  security_domain_quorum                    = local.activate ? var.security_domain_quorum : null

  tags = local.tags
}

# Optional custom data-plane role definitions (pure RBAC; no access policies).
resource "azurerm_key_vault_managed_hardware_security_module_role_definition" "this" {
  for_each = var.role_definitions

  managed_hsm_id = azurerm_key_vault_managed_hardware_security_module.this.id
  name           = each.key
  role_name      = each.value.role_name
  description    = each.value.description

  dynamic "permission" {
    for_each = each.value.permissions
    content {
      data_actions = permission.value.data_actions
    }
  }
}

# Role assignments grant a principal a (built-in or custom) role at a scope,
# e.g. "/keys" for Crypto User / Crypto Officer over all keys.
resource "azurerm_key_vault_managed_hardware_security_module_role_assignment" "this" {
  for_each = var.role_assignments

  managed_hsm_id     = azurerm_key_vault_managed_hardware_security_module.this.id
  name               = each.value.name
  scope              = each.value.scope
  role_definition_id = each.value.role_definition_id
  principal_id       = each.value.principal_id
}

# HSM-backed keys can only be created AFTER activation, and the principal
# creating them needs the Crypto User/Officer role. depends_on enforces that
# ordering so the first apply does not race the role assignments.
resource "azurerm_key_vault_managed_hardware_security_module_key" "this" {
  for_each = local.activate ? var.keys : {}

  name           = each.key
  managed_hsm_id = azurerm_key_vault_managed_hardware_security_module.this.id
  key_type       = each.value.key_type
  key_opts       = each.value.key_opts

  # key_size is required for RSA-HSM/oct-HSM; curve is required for EC-HSM.
  key_size = each.value.key_type == "EC-HSM" ? null : each.value.key_size
  curve    = each.value.key_type == "EC-HSM" ? each.value.curve : null

  expiration_date = each.value.expiration_date

  tags = local.tags

  depends_on = [
    azurerm_key_vault_managed_hardware_security_module_role_assignment.this,
  ]
}

variables.tf

variable "name" {
  description = "Globally-unique Managed HSM name (3-24 chars, alphanumeric and hyphens)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-]{3,24}$", var.name))
    error_message = "name must be 3-24 characters using only letters, digits, and hyphens."
  }
}

variable "resource_group_name" {
  description = "Resource group that will contain the Managed HSM."
  type        = string
}

variable "location" {
  description = "Azure region with Managed HSM availability (e.g. eastus, westeurope, centralindia)."
  type        = string
}

variable "sku_name" {
  description = "Managed HSM SKU. Only Standard_B1 is currently supported."
  type        = string
  default     = "Standard_B1"

  validation {
    condition     = contains(["Standard_B1"], var.sku_name)
    error_message = "sku_name must be Standard_B1 (the only Managed HSM SKU)."
  }
}

variable "tenant_id" {
  description = "Entra ID (Azure AD) tenant ID used to authenticate data-plane requests."
  type        = string

  validation {
    condition     = can(regex("^[0-9a-fA-F-]{36}$", var.tenant_id))
    error_message = "tenant_id must be a GUID."
  }
}

variable "admin_object_ids" {
  description = "Entra ID object IDs granted HSM administrator (control-plane) rights. Changing forces replacement."
  type        = list(string)

  validation {
    condition     = length(var.admin_object_ids) > 0
    error_message = "At least one administrator object ID is required."
  }
}

variable "purge_protection_enabled" {
  description = "Enable purge protection. REQUIRED for production; blocks permanent purge during the retention window. Forces a new resource."
  type        = bool
  default     = true
}

variable "soft_delete_retention_days" {
  description = "Days a soft-deleted HSM (and its keys) is retained before it can be purged (7-90). Forces a new resource."
  type        = number
  default     = 90

  validation {
    condition     = var.soft_delete_retention_days >= 7 && var.soft_delete_retention_days <= 90
    error_message = "soft_delete_retention_days must be between 7 and 90."
  }
}

variable "public_network_access_enabled" {
  description = "Whether the HSM accepts traffic from public networks. Disable and use private endpoints for the strongest posture."
  type        = bool
  default     = true
}

variable "network_acls" {
  description = "Network ACLs for the HSM data plane."
  type = object({
    bypass         = optional(string, "AzureServices")
    default_action = optional(string, "Deny")
  })
  default = {}

  validation {
    condition     = contains(["AzureServices", "None"], var.network_acls.bypass)
    error_message = "network_acls.bypass must be AzureServices or None."
  }

  validation {
    condition     = contains(["Allow", "Deny"], var.network_acls.default_action)
    error_message = "network_acls.default_action must be Allow or Deny."
  }
}

variable "security_domain_certificate_ids" {
  description = <<-EOT
    Key Vault certificate resource IDs (3-10) used to activate the HSM's
    security domain. Leave empty to provision WITHOUT activating (perform the
    activation ceremony out-of-band). When set, security_domain_quorum is
    required and Terraform activates the HSM during apply.
  EOT
  type        = list(string)
  default     = []

  validation {
    condition     = length(var.security_domain_certificate_ids) == 0 || (length(var.security_domain_certificate_ids) >= 3 && length(var.security_domain_certificate_ids) <= 10)
    error_message = "Provide between 3 and 10 certificate IDs to activate, or none to skip in-band activation."
  }
}

variable "security_domain_quorum" {
  description = "Minimum number of security-domain key shares required to recover the domain (2-10). Required when activating."
  type        = number
  default     = 2

  validation {
    condition     = var.security_domain_quorum >= 2 && var.security_domain_quorum <= 10
    error_message = "security_domain_quorum must be between 2 and 10."
  }
}

variable "role_definitions" {
  description = "Custom data-plane role definitions keyed by a stable local name."
  type = map(object({
    role_name   = string
    description = optional(string, "Managed by Terraform")
    permissions = list(object({
      data_actions = list(string)
    }))
  }))
  default = {}
}

variable "role_assignments" {
  description = <<-EOT
    Data-plane role assignments keyed by a stable local name. Each value:
      name               - a GUID for the assignment
      scope              - e.g. "/" or "/keys"
      role_definition_id - built-in or custom role definition resource ID
      principal_id       - Entra ID object ID to grant the role to
  EOT
  type = map(object({
    name               = string
    scope              = string
    role_definition_id = string
    principal_id       = string
  }))
  default = {}
}

variable "keys" {
  description = <<-EOT
    HSM-backed keys to create (only when the HSM is activated). Each value:
      key_type        - EC-HSM | RSA-HSM | oct-HSM
      key_opts        - JSON web key operations (e.g. ["sign","verify"])
      key_size        - required for RSA-HSM/oct-HSM (e.g. 2048, 3072, 4096)
      curve           - required for EC-HSM (P-256 | P-256K | P-384 | P-521)
      expiration_date - optional UTC RFC3339 expiry
  EOT
  type = map(object({
    key_type        = string
    key_opts        = list(string)
    key_size        = optional(number)
    curve           = optional(string)
    expiration_date = optional(string)
  }))
  default = {}

  validation {
    condition = alltrue([
      for k in values(var.keys) : contains(["EC-HSM", "RSA-HSM", "oct-HSM"], k.key_type)
    ])
    error_message = "Each key_type must be EC-HSM, RSA-HSM, or oct-HSM."
  }
}

variable "tags" {
  description = "Tags merged with module defaults and applied to the HSM and its keys."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Managed HSM."
  value       = azurerm_key_vault_managed_hardware_security_module.this.id
}

output "name" {
  description = "Name of the Managed HSM."
  value       = azurerm_key_vault_managed_hardware_security_module.this.name
}

output "hsm_uri" {
  description = "Data-plane URI of the HSM (e.g. https://<name>.managedhsm.azure.net) for key operations."
  value       = azurerm_key_vault_managed_hardware_security_module.this.hsm_uri
}

output "security_domain_encrypted_data" {
  description = "Encrypted security-domain blob (only populated after in-band activation). Store offline for DR; treat as sensitive."
  value       = try(azurerm_key_vault_managed_hardware_security_module.this.security_domain_encrypted_data, null)
  sensitive   = true
}

output "key_ids" {
  description = "Map of key name => versionless key ID for HSM-backed keys created by this module."
  value       = { for k, key in azurerm_key_vault_managed_hardware_security_module_key.this : k => key.id }
}

output "key_versioned_ids" {
  description = "Map of key name => versioned key ID (pin this on CMK consumers that require a specific version)."
  value       = { for k, key in azurerm_key_vault_managed_hardware_security_module_key.this : k => key.versioned_id }
}

output "role_definition_ids" {
  description = "Map of custom role definition local name => resource ID."
  value       = { for k, rd in azurerm_key_vault_managed_hardware_security_module_role_definition.this : k => rd.resource_manager_id }
}

How to use it

data "azurerm_client_config" "current" {}

# Three RSA certificates for the security-domain quorum. In production these
# live in a Key Vault and their PRIVATE keys are held offline by security
# officers — Terraform only references the public certificate IDs.
resource "azurerm_key_vault_certificate" "sd" {
  count        = 3
  name         = "hsm-sd-${count.index}"
  key_vault_id = azurerm_key_vault.security.id

  certificate_policy {
    issuer_parameters { name = "Self" }
    key_properties {
      exportable = true
      key_type   = "RSA"
      key_size   = 2048
      reuse_key  = false
    }
    secret_properties { content_type = "application/x-pkcs12" }
    x509_certificate_properties {
      key_usage          = ["digitalSignature", "keyEncipherment"]
      subject            = "CN=hsm-sd-${count.index}"
      validity_in_months = 12
    }
  }
}

module "managed_hsm" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-managed-hsm?ref=v1.0.0"

  name                = "kvhsm-payments-prod"
  resource_group_name = azurerm_resource_group.security.name
  location            = "eastus"
  tenant_id           = data.azurerm_client_config.current.tenant_id
  admin_object_ids    = [data.azurerm_client_config.current.object_id]

  purge_protection_enabled      = true
  soft_delete_retention_days    = 90
  public_network_access_enabled = false

  network_acls = {
    bypass         = "AzureServices"
    default_action = "Deny"
  }

  # In-band activation: 3 certs + a quorum of 2.
  security_domain_certificate_ids = azurerm_key_vault_certificate.sd[*].id
  security_domain_quorum          = 2

  # Grant the deploying principal Crypto User over all keys (built-in role ID).
  role_assignments = {
    crypto_user = {
      name               = "00000000-0000-0000-0000-0000000000aa"
      scope              = "/keys"
      role_definition_id = "/Microsoft.KeyVault/providers/Microsoft.Authorization/roleDefinitions/21dbd100-6940-42c2-9190-5d6cb909625b"
      principal_id       = data.azurerm_client_config.current.object_id
    }
  }

  # An RSA-HSM key for Storage customer-managed encryption.
  keys = {
    storage-cmk = {
      key_type = "RSA-HSM"
      key_size = 3072
      key_opts = ["wrapKey", "unwrapKey"]
    }
  }

  tags = {
    env         = "prod"
    workload    = "payments"
    cost_center = "FIN-204"
  }
}

# Downstream: point a Storage account's CMK at the HSM-backed key.
resource "azurerm_storage_account_customer_managed_key" "this" {
  storage_account_id = azurerm_storage_account.payments.id
  managed_hsm_key_id = module.managed_hsm.key_versioned_ids["storage-cmk"]
}

Activation is irreversible and destructive to lose. The security_domain_encrypted_data output is the only way to recover the HSM into a replacement cluster. Persist it to offline, access-controlled storage the moment activation completes, and never commit it to version control.

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_hsm/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-hsm?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  tenant_id = "..."
  admin_object_ids = ["..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/managed_hsm && 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 Globally-unique HSM name (3–24 chars, alphanumeric/hyphen).
resource_group_name string Yes Resource group that will contain the HSM.
location string Yes Azure region with Managed HSM availability.
sku_name string Standard_B1 No Managed HSM SKU (only Standard_B1 today).
tenant_id string Yes Entra ID tenant ID for data-plane auth.
admin_object_ids list(string) Yes Object IDs of HSM administrators (forces new).
purge_protection_enabled bool true No Enable purge protection (required for prod; forces new).
soft_delete_retention_days number 90 No Soft-delete retention in days (7–90; forces new).
public_network_access_enabled bool true No Allow public-network traffic; disable for private-endpoint-only.
network_acls object {} No bypass (AzureServices/None) and default_action (Allow/Deny).
security_domain_certificate_ids list(string) [] No 3–10 Key Vault cert IDs to activate; empty = provision without activating.
security_domain_quorum number 2 No Min shares to recover the security domain (2–10); used when activating.
role_definitions map(object) {} No Custom data-plane role definitions.
role_assignments map(object) {} No Role assignments (name/scope/role_definition_id/principal_id).
keys map(object) {} No HSM-backed keys (created only after activation).
tags map(string) {} No Tags merged with module defaults.

Outputs

Name Description
id Resource ID of the Managed HSM.
name Name of the Managed HSM.
hsm_uri Data-plane URI for key operations.
security_domain_encrypted_data Encrypted security-domain blob (post-activation); store offline for DR.
key_ids Map of key name → versionless key ID.
key_versioned_ids Map of key name → versioned key ID.
role_definition_ids Map of custom role definition name → resource ID.

Enterprise scenario

A payments platform must hold its TDE and storage encryption keys in FIPS 140-2 Level 3 hardware to satisfy PCI scope. The platform security team publishes this module at v1.0.0 and stands up one kvhsm-payments-prod HSM per region with purge_protection_enabled = true, public_network_access_enabled = false, and network_acls.default_action = "Deny". The security-domain activation is a controlled ceremony: three officers each generate an RSA certificate whose private key never leaves their offline smart card, the three public certificate IDs are supplied to the module, and security_domain_quorum = 2 means any two of the three officers can recover the domain in a disaster. Once activated, the module’s security_domain_encrypted_data output is exported to an offline vault. Workload teams never touch the HSM directly — they receive a scoped Crypto User role assignment over /keys, and consume key_versioned_ids outputs for Storage and SQL customer-managed keys. A quarterly audit confirms every CMK in the payments estate is HSM-backed, every HSM has purge protection, and no HSM is reachable from the public internet.

Best practices


Part of the KloudVin Terraform module library. Pair this with the Key Vault, Storage Account, and Private Endpoint modules — they consume the key_versioned_ids and hsm_uri this module outputs.

TerraformAzureManaged HSMModuleIaC
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