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:
- Almost everything forces a new resource.
name,location,admin_object_ids,sku_name,tenant_id, andpurge_protection_enabledall force replacement. Because the HSM also has soft-delete and (when enabled) purge protection, a careless edit does not “update in place” — it tries to destroy and recreate a resource that may be impossible to purge, leaving you stuck. The safe pattern is to settle these at creation and treat them as immutable. - A freshly created HSM is not usable until it is activated with a security domain. This is the single most important fact about Managed HSM. When the cluster is provisioned it is in an un-activated state: no keys, no cryptographic operations. Activation is a one-time ceremony where you supply 3 to 10 RSA public-key certificates and a quorum (the minimum number of corresponding private keys required to later decrypt/recover the security domain). Azure encrypts the security domain to those public keys and returns an encrypted blob you must store offline — losing it (or losing quorum of the private keys) means the HSM and every key in it are permanently unrecoverable.
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
- You need customer-managed keys backed by FIPS 140-2 Level 3 hardware for Storage, Managed Disks, SQL/PostgreSQL TDE, or Azure Information Protection, and the standard Key Vault’s shared-HSM tier is not sufficient for your compliance scope (PCI-HSM, FIPS L3, sovereign requirements).
- You are building a centralized key-management landing zone where one team owns the HSM, the security-domain ceremony, and the RBAC model, and hands cryptographic-operation access to workloads via scoped role assignments.
- You run BYOK / HYOK flows and want the root of the key hierarchy to live in a single-tenant HSM whose security domain you control and can export for disaster recovery into a second HSM.
- You need separation of duties enforced in the data plane — Crypto Officers who can purge keys, Crypto Users who can use but not delete them, and custom role definitions for everything in between — all expressed as code and reviewed in a PR.
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_dataoutput 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 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_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
- Treat creation-time arguments as immutable.
name,location,sku_name,tenant_id,admin_object_ids, andpurge_protection_enabledall force replacement — and with purge protection on, a replacement can strand you behind soft-delete. Decide these once, pin the module with?ref=<tag>, and review any change as a destructive operation. - Never lose the security domain. Capture
security_domain_encrypted_datato offline, access-controlled storage immediately after activation, and protect the certificate private keys per your quorum. Losing quorum of those private keys makes the HSM and every key in it permanently unrecoverable — there is no Microsoft backdoor. - Default to private and deny. Keep
public_network_access_enabled = falseandnetwork_acls.default_action = "Deny", and reach the HSM over a private endpoint. The HSM holds your most sensitive keys; it should not answer on the public internet. - Separate activation from provisioning when policy demands it. Leave
security_domain_certificate_idsempty so the pipeline provisions the cluster, then perform the activation ceremony out-of-band with a separate quorum of security officers; only switch to in-band activation when an automated, audited ceremony is acceptable. - Model permissions with RBAC and least privilege. Grant
Crypto Userfor use-only workloads and reserveCrypto Officer(key purge) for break-glass identities; scope assignments to/keysrather than/where possible, and express custom needs asrole_definitionsso the permission model is reviewable in code. - Order key creation after activation and role assignments. HSM keys cannot be minted before activation, and the creating principal needs a crypto role first. This module gates
keyson activation anddepends_onthe role assignments — keep that ordering so the first apply does not race itself.
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.