Creates an HSM-protected key inside an Azure Key Vault Managed HSM. Creating it requires data-plane RBAC that Azure RBAC cannot grant. Targets
hashicorp/azurerm ~> 4.0.
- ☁️ Creates one
azurerm_key_vault_managed_hardware_security_module_key(the keystonethis) — a key generated inside FIPS 140-2 Level 3 hardware. - 🔴 Records the failure that catches everyone once: a Managed HSM has two independent authorization systems, and
Owneron the resource grants no ability to create a key. - 🔒 Emits no key material at all — not even the public half, unlike
azurerm_key_vault_key. There is nothing here to redact and nothing to leak. - 🔴 Flags both dates as a one-way door: once set, an expiry can never be unset, and deleting either date argument forces replacement -- moving one, in either direction, does not.
- 🧮 Emits both the unversioned
idand theversioned_id, and explains which one a consumer should reference.
💡 Why it matters: every HSM key type here is hardware-protected, so the interesting risks are not about algorithm choice — they are about who is permitted to use the key, and which URI form a consumer pinned.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
rg["terraform-azurerm-resource-group"]
certs["terraform-azurerm-key-vault emits certificate_ids for the security domain"]
mhsm["terraform-azurerm-key-vault-managed-hardware-security-module ARM Microsoft.KeyVault/managedHSMs"]
ra["terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment LOCAL RBAC"]
key["terraform-azurerm-key-vault-managed-hardware-security-module-key"]
rot["terraform-azurerm-key-vault-managed-hardware-security-module-key-rotation-policy at most ONE per key"]
vid["versioned_id changes on every rotation"]
uid["id the unversioned data-plane URI"]
consumer["terraform-azurerm-disk-encryption-set and other CMK consumers"]
rg -->|"name and location"| mhsm
certs -->|"certificate_ids for the security domain"| mhsm
mhsm -->|"id as managed_hsm_id, an ARM path"| key
mhsm -->|"id"| ra
ra -.->|"Crypto User must exist first, or create fails"| key
key -->|"id as managed_hsm_key_id, a data-plane URI"| rot
key --> uid
key --> vid
uid -->|"follows rotations"| consumer
vid -->|"pinned, does NOT follow rotations"| consumer
classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
classDef keystone fill:#004578,stroke:#00243c,color:#ffffff;
classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
class key this;
class mhsm keystone;
class rg,certs,ra,rot,vid,uid,consumer neutral;
Two edges deserve attention. The dotted one is a local-RBAC role assignment that must exist before this key can be created — a dependency no input on this module can express. And the split at the bottom is the choice between the unversioned id, which follows rotations, and versioned_id, which does not.
flowchart TB
name["name force-new, rejects both a Resource ID and a URI"]
hsmid["managed_hsm_id force-new, ARM path, rejects the hsm_uri form"]
kt["key_type force-new, EC-HSM or oct-HSM or RSA-HSM, all HSM-protected"]
opts["key_opts a SET, case-sensitive, empty is allowed and reported"]
curve["curve required for EC-HSM only"]
size["key_size required for RSA-HSM and oct-HSM only"]
dates["expiration_date a ONE-WAY DOOR plus not_before_date"]
tags["tags present here, absent on the rotation policy"]
res["azurerm_key_vault_managed_hardware_security_module_key.this"]
uri["id a DATA-PLANE URI, not an ARM Resource ID"]
vid["versioned_id changes on every rotation"]
nopub["no_public_key_material_is_emitted_by_this_resource"]
rbac["creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant"]
name --> res
hsmid --> res
kt -->|"decides which of the two below applies"| curve
kt --> size
kt --> res
opts --> res
curve --> res
size --> res
dates --> res
tags --> res
res --> uri
res --> vid
res --> nopub
res --> rbac
classDef this fill:#0078D4,stroke:#004578,color:#ffffff;
classDef neutral fill:#F3F2F1,stroke:#8A8886,color:#201F1E;
class res this;
class name,hsmid,kt,opts,curve,size,dates,tags,uri,vid,nopub,rbac neutral;
| Resource | Cardinality | Purpose |
|---|---|---|
azurerm_key_vault_managed_hardware_security_module_key.this |
single, many per Managed HSM | One HSM-protected key. |
Nine arguments plus timeouts. key_type decides which of curve and key_size applies — and forbids the other.
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | hashicorp/azurerm ~> 4.0 |
| Provider block | None in this module. The caller configures provider "azurerm" { features {} }, auth and subscription. |
| Module type | Standalone — one resource, no children. |
| ARM provider | Microsoft.KeyVault |
Schema notes that bite:
- 🔴 Creating a key is a data-plane operation. Azure RBAC governs the control plane only; keys need Managed HSM local RBAC. Azure's own security baseline records "Azure RBAC for Data Plane: Supported — False" for this service.
- 🔴 This resource emits no key material, not even the public key. No
public_key_pem, nopublic_key_openssh, noe/n/x/y— all of whichazurerm_key_vault_keydoes emit. Fetch the public key from the data plane instead. - 🔴
idis a data-plane URI,https://<hsm>.managedhsm.azure.net/keys/<name>, not an ARM Resource ID. Meanwhilemanaged_hsm_idis an ARM path. One family, two conventions. - 🔴
expiration_datecan never be unset once set — the provider states the API restores the purged key, so even destroy-and-recreate brings it back. - 🔴 Both dates are force-new in one direction only, and the direction is REMOVAL. The
CustomizeDiffpredicate is old value non-empty, new value empty, so deletingexpiration_dateornot_before_datereplaces the key. Moving a date is an in-place update whichever way it moves. Under the provider defaults a replacement soft-deletes the key and then recovers it -- same material, same versions, old properties restored -- so the edit is churn and a no-op. Only withpurge_soft_deleted_hsm_keys_on_destroyenabled and HSM purge protection off is the key genuinely purged and a new one generated, and only then is anything wrapped under it unrecoverable. - 🔴 There is no software key type. Only
EC-HSM,oct-HSMandRSA-HSM; the plainRSAandECvalues from Key Vault do not exist here. ⚠️ key_optsis case-sensitive and is a set here where Key Vault uses a list.wrapKey, notwrapkey.⚠️ There is no inlinerotation_policyblock, unlikeazurerm_key_vault_key— rotation is a separate resource, which is why the sibling module exists.⚠️ Destroy behaviour depends on the caller'sfeatures {}block —purge_soft_deleted_hardware_security_module_keys_on_destroy.⚠️ Force-new:name,managed_hsm_id,key_type,key_size,curve, andexpiration_date/not_before_datewhen removed.⚠️ key_sizeandcurveareExactlyOneOfeach other -- a cross-field rule absent from the binary schema. This module'skey_type-keyed checks are stricter and imply it in both directions.⚠️ curvecarries a diff suppression: a storedSECP256K1equals a configuredP-256K. Same curve, old and new names; there is nothing to correct.- 🔴 Any update rotates the version.
versioned_idis marked newly-computed wheneverkey_opts, either date ortagschange -- so editing a tag produces a new key version. ⚠️ All four timeouts exist. A misspelled key is discarded silently.
Two planes, and only one of them is Azure RBAC.
| Plane | Operation | Role | Scope |
|---|---|---|---|
| Data plane (local RBAC) | Create, read, update, delete this key | Managed HSM Crypto User | /keys or /keys/<key-name> |
| Data plane (local RBAC) | Purge a soft-deleted key | Managed HSM Crypto Officer | /keys or /keys/<key-name> |
| Data plane (local RBAC) | Rotate / create new versions | Managed HSM Crypto Officer | /keys or /keys/<key-name> |
| Control plane (Azure RBAC) | Manage the Managed HSM resource, read its tags | Managed HSM Contributor | the Managed HSM |
| Control plane (Azure RBAC) | Read the resource record | Reader | the Managed HSM |
🔴
OwnerorContributoron the Managed HSM grants nothing here. Microsoft documents a dual-plane model: control-plane access does not confer data-plane access. The identity running Terraform needs a local RBAC assignment, and without it the apply fails on authorization while every Azure RBAC check looks correct.
⚠️ And that assignment is an ordering problem, not just a permissions one. A configuration that creates the HSM and this key in one pass will try to create the key before the identity is permitted to. See example 3.
✅ Plan access here is not credential access. Refreshing this resource needs data-plane read, but the provider returns no key material — so an identity granted plan rights learns metadata and never bytes. That is unusual enough in this library to be worth stating explicitly.
🔒 Assign administrative roles to Entra security groups rather than individuals, which Microsoft recommends to avoid locking yourself out of an HSM when an account is deleted, and consider PIM for just-in-time elevation.
- An existing Managed HSM, activated with its security domain downloaded. See
terraform-azurerm-key-vault-managed-hardware-security-module. - A Managed HSM local-RBAC role assignment granting the Terraform identity Crypto User at
/keys, created before this key. Useterraform-azurerm-key-vault-managed-hardware-security-module-role-assignment, and order it withdepends_on— nothing in this module's inputs can express that dependency. Microsoft.KeyVaultregistered in the subscription.- Network reachability of the HSM's data plane if it restricts public access — the data-plane endpoint is what Terraform talks to, not
management.azure.com. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; this module declares none of these.
terraform-azurerm-key-vault-managed-hardware-security-module-key/
├── providers.tf # required_version + pinned azurerm; no provider block
├── variables.tf # 10 inputs, 17 validations
├── main.tf # the keystone `this` + ID-parsing and reporting locals
├── outputs.tf # id first, then what the module cannot grant or verify
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "hsm_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-storage-cmk"
managed_hsm_id = module.managed_hsm.id
key_type = "RSA-HSM"
key_size = 4096
key_opts = ["wrapKey", "unwrapKey"]
tags = { environment = "prod" }
}🔴 This will fail unless the Terraform identity already holds Crypto User on the HSM's
/keysscope. Example 3 shows the full ordering.
💡
wrapKey/unwrapKeyonly. A key used to protect another key does not needsignorencrypt.
Consumes
| Input | Type | Source |
|---|---|---|
name |
string |
caller — force-new |
managed_hsm_id |
string |
terraform-azurerm-key-vault-managed-hardware-security-module output id (the ARM path, not hsm_uri) — force-new |
key_type |
string |
caller — EC-HSM / oct-HSM / RSA-HSM, force-new |
key_opts |
set(string) |
caller — case-sensitive |
key_size |
number |
caller — required for RSA/oct, forbidden for EC, force-new |
curve |
string |
caller — required for EC, forbidden otherwise, force-new |
expiration_date / not_before_date |
string |
caller — UTC …Z form |
tags |
map(string) |
caller |
timeouts |
object(...) |
caller |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The data-plane URI. Unversioned. | the rotation-policy module; terraform-azurerm-disk-encryption-set |
versioned_id |
The version-pinned URI. Changes on rotation. | consumers that must not follow a rotation |
name / managed_hsm_id / key_type / key_size / curve |
Configuration echo. Force-new. | review |
expiration_date / not_before_date |
The dates, or null. | review |
tags |
The effective tags. | review |
managed_hsm_name / managed_hsm_resource_group |
Derived from the ARM ID. | inventory |
permitted_operations |
Derived, sorted. | review |
key_permits_no_operations |
Derived. Reported, not rejected. | review |
rsa_key_size_is_below_the_modern_2048_bit_floor |
Derived. Reported, not rejected. | security review |
no_public_key_material_is_emitted_by_this_resource |
Always true. |
integration design |
creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant |
Always true. |
permissions review |
expiration_date_can_never_be_unset_once_set |
Always true. |
change review |
destroy_behaviour_depends_on_a_provider_features_toggle_the_caller_owns |
Always true. |
change review |
1 · An RSA wrapping key
module "hsm_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-storage-cmk"
managed_hsm_id = module.managed_hsm.id
key_type = "RSA-HSM"
key_size = 4096
key_opts = ["wrapKey", "unwrapKey"]
}ℹ️
key_sizeis required forRSA-HSMand would be rejected forEC-HSM, wherecurvetakes its place.
2 · An EC signing key
module "hsm_signing_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-code-signing"
managed_hsm_id = module.managed_hsm.id
key_type = "EC-HSM"
curve = "P-384"
key_opts = ["sign", "verify"]
}
⚠️ It isP-521, notP-512. That curve genuinely is 521 bits. AndP-256Kis the Koblitz curve secp256k1 — a different curve fromP-256, not a variant of it.
3 · 🔴 The local-RBAC ordering that makes the difference between working and not
data "azurerm_client_config" "current" {}
module "managed_hsm" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module.git?ref=v1.0.0"
name = "hsm-prod"
resource_group_name = module.rg.name
location = "eastus2"
tenant_id = data.azurerm_client_config.current.tenant_id
admin_object_ids = [data.azurerm_client_config.current.object_id]
}
# Managed HSM Crypto User -- lets the identity create and delete keys.
module "crypto_user" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"
managed_hsm_id = module.managed_hsm.id
name = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
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
}
module "hsm_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-storage-cmk"
managed_hsm_id = module.managed_hsm.id
key_type = "RSA-HSM"
key_size = 4096
key_opts = ["wrapKey", "unwrapKey"]
# Nothing in this module's inputs can express this dependency: the role
# assignment is a sibling resource, and the key does not reference it.
depends_on = [module.crypto_user]
}🔴 Without the
depends_on, a first apply usually fails. Terraform sees no data dependency between the key and the role assignment, so it may create the key first — before the identity is permitted to. The error is an authorization failure on the data plane, which reads like a mistake in the HSM configuration.
⚠️ nameon the role assignment is a GUID you choose, not a friendly name. Therole_definition_idGUID above is the built-in Crypto User role.
💡
depends_onIS valid on amoduleblock — it islifecyclethat is not. So this is expressible from the caller's side even though the module cannot express it internally.
4 · Why `Contributor` is not enough
output "read_this_before_granting_access" {
# Always true.
value = module.hsm_key.creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant
}# Control plane -- Azure RBAC. Does NOT let you create a key.
az role assignment create --role "Managed HSM Contributor" --assignee <id> --scope <hsm resource id>
# Data plane -- Managed HSM local RBAC. This is the one that matters.
az keyvault role assignment create --hsm-name hsm-prod \
--role "Managed HSM Crypto User" --assignee <id> --scope /keys🔴 Two different CLI commands, two different systems.
az role assignmentis Azure RBAC;az keyvault role assignmentis local RBAC. Microsoft's security baseline states plainly that Azure RBAC for the data plane is not supported on this service.
ℹ️ Local RBAC has exactly two scope shapes:
/or/keysfor the whole HSM, and/keys/<key-name>for one key. Prefer the second where an application needs one key.
5 · 🔒 No public key comes out of Terraform
output "what_you_can_and_cannot_get" {
value = {
# Available.
unversioned = module.hsm_key.id
versioned = module.hsm_key.versioned_id
# NOT available -- this resource emits no key material at all.
no_public_key = module.hsm_key.no_public_key_material_is_emitted_by_this_resource
}
}# The only way to obtain the public key.
az keyvault key download --hsm-name hsm-prod --name key-code-signing --file public.pem🔴
azurerm_key_vault_keyemitspublic_key_pem,public_key_openssh,e,n,xandy. This resource emits none of them. Any pipeline that assumes a key resource yields a PEM needs rewriting for Managed HSM.
✅ That is a stronger position than redaction, not a gap. There is nothing to mark
sensitivebecause there is nothing to expose. Worth remembering whatsensitive = truewould have bought anyway: it redacts plan output and does not encrypt state — the control that matters is an encrypted, access-controlled backend, never a local state file in a repository.
6 · 🔴 `expiration_date` is a one-way door
module "hsm_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-tde"
managed_hsm_id = module.managed_hsm.id
key_type = "RSA-HSM"
key_size = 3072
key_opts = ["wrapKey", "unwrapKey"]
# Setting this cannot be undone. Read the output below first.
expiration_date = "2027-12-31T23:59:59Z"
}
output "understand_before_setting_an_expiry" {
value = module.hsm_key.expiration_date_can_never_be_unset_once_set
}🔴 Removing the argument later does not clear the expiry. The provider states that the underlying API restores the purged key, so even destroying and recreating brings the expiry back. Terraform stops managing the value; Azure keeps enforcing it.
🔴 And it is force-new in one direction only -- the direction is REMOVAL, not direction of travel. The
CustomizeDiffpredicate is old value non-empty, new value empty, so deleting the argument replaces the key; moving the date is an ordinary in-place update either way.not_before_datecarries the identical rule. Under the provider defaults a replacement soft-deletes the key and then recovers it -- same material, same versions, old properties restored -- so the edit is churn and a no-op. Only withpurge_soft_deleted_hsm_keys_on_destroyenabled and HSM purge protection off is the key genuinely purged and a new one generated, and only then is anything wrapped under it unrecoverable. Read together with the note above: deleting the line replaces the key and the recovered key brings the expiry back with it.
💡 Prefer a rotation policy. The sibling module expresses expiry as a duration applied to each newly rotated key, so you never edit an absolute date on a key something depends on.
7 · The two ID conventions, and how each is rejected
# Rejected -- this is the parent module's `hsm_uri` output, not its `id`.
managed_hsm_id = "https://hsm-prod.managedhsm.azure.net/"
# Rejected -- a KEY VAULT, not a Managed HSM. A vault key is azurerm_key_vault_key.
managed_hsm_id = ".../providers/Microsoft.KeyVault/vaults/kv-prod"
# Accepted.
managed_hsm_id = ".../providers/Microsoft.KeyVault/managedHSMs/hsm-prod"# And in the other direction -- `name` rejects the URI this resource EMITS.
name = "https://hsm-prod.managedhsm.azure.net/keys/key-tde" # rejected
name = "key-tde" # accepted
⚠️ The parent module emits bothidandhsm_uri, and both are legitimate values elsewhere in this family — which is exactly why passing the wrong one is easy. The sibling rotation-policy module takes the URI form, so the two modules want opposite things.
ℹ️ The
managedHSMspattern is matched case-insensitively, because Azure returns that segment capitalised while other tooling lower-cases it, and a case difference is not a real error.
8 · `key_opts` is case-sensitive — and unknown values are allowed
# Rejected -- differs from a documented operation only by case.
key_opts = ["sign", "wrapkey"]
# Rejected -- same reason.
key_opts = ["WrapKey"]
# Accepted -- correct casing.
key_opts = ["wrapKey", "unwrapKey"]
# ACCEPTED -- unrecognised, so possibly an operation added since.
key_opts = ["sign", "somethingNew"]
⚠️ The camelCase pair is where this goes wrong:wrapKeyandunwrapKey. The provider states the values are case-sensitive and documents the list as operations it includes, so this module rejects the near miss and lets an unknown through.
ℹ️ A set, not a list — reordering never produces a diff.
azurerm_key_vault_keyuses a list for the same argument.
9 · A key that permits nothing — reported, not rejected
module "hsm_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-placeholder"
managed_hsm_id = module.managed_hsm.id
key_type = "EC-HSM"
curve = "P-521"
# Accepted by the provider, and therefore accepted here.
key_opts = []
}
output "almost_certainly_a_mistake" {
value = module.hsm_key.key_permits_no_operations
}almost_certainly_a_mistake = true
ℹ️ The provider documents no minimum, so refusing an empty set would be inventing a constraint. But a key permitting nothing fails when an application tries to use it, not at apply — so the fact is surfaced here instead. It costs an HSM partition either way.
10 · RSA key size — a report, not a floor
module "legacy_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-legacy-interop"
managed_hsm_id = module.managed_hsm.id
key_type = "RSA-HSM"
key_size = 1024
key_opts = ["wrapKey", "unwrapKey", "sign"]
}
output "worth_challenging" {
value = module.legacy_key.rsa_key_size_is_below_the_modern_2048_bit_floor
}worth_challenging = true
⚠️ Reported rather than rejected, and the reason is the documentation itself — the provider offers 1024 as an examplekey_size, so this module will not refuse a value its own source of truth presents as legitimate. Overriding the provider on a published point is not this module's job.
🔴 1024-bit RSA is below every current recommendation, and a key generated inside an HSM is no stronger than its modulus. If this reads
true, the question is whether the size was chosen or copied.
ℹ️ The flag is
key_type-aware. Anoct-HSMkey of 256 bits is a normal AES length and correctly readsfalse; onlyRSA-HSMis assessed.
⚠️ A documentation wrinkle to know about: the provider describeskey_sizeas being in bytes and then gives bit values as examples. Azure reads it as bits. Pass the familiar bit lengths.
11 · Versioned or unversioned — the choice that outlives this module
# A disk encryption set that should FOLLOW rotations.
module "disk_encryption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-disk-encryption-set.git?ref=v1.0.0"
name = "des-prod"
resource_group_name = module.rg.name
location = "eastus2"
# The UNVERSIONED uri -- resolves to whatever the current version is.
managed_hsm_key_id = module.hsm_key.id
}
output "pinning" {
value = {
follows_rotation = module.hsm_key.id
pinned_forever = module.hsm_key.versioned_id
}
}💡 Reference
idwhere a consumer should benefit from rotation, andversioned_idwhere it must not. Encryption at rest generally wants the first; something that must keep verifying old signatures wants the second.
⚠️ versioned_idchanges on every rotation, so a configuration that hard-codes its literal value drifts the moment a rotation fires. Reference the output rather than copying it.
12 · Many keys on one HSM
variable "keys" {
type = map(object({
key_type = string
key_size = optional(number)
curve = optional(string)
key_opts = set(string)
}))
default = {
storage_cmk = { key_type = "RSA-HSM", key_size = 4096, key_opts = ["wrapKey", "unwrapKey"] }
sql_tde = { key_type = "RSA-HSM", key_size = 3072, key_opts = ["wrapKey", "unwrapKey"] }
signing = { key_type = "EC-HSM", curve = "P-384", key_opts = ["sign", "verify"] }
}
}
module "hsm_keys" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
for_each = var.keys
name = "key-${each.key}"
managed_hsm_id = module.managed_hsm.id
key_type = each.value.key_type
key_size = try(each.value.key_size, null)
curve = try(each.value.curve, null)
key_opts = each.value.key_opts
tags = { purpose = each.key }
}✅ A Managed HSM holds many keys, so
for_eachis the right shape here — unlike the sibling rotation policy, of which a key has at most one.
💡 The optional
key_size/curvepair per entry mirrors the resource's own rule, so each key supplies only the sizing argument itskey_typepermits.
13 · 🏗️ End-to-end composition — an HSM-backed CMK with rotation
provider "azurerm" {
features {
# DECIDES whether destroying a key soft-deletes or purges it.
key_vault {
purge_soft_deleted_hardware_security_module_keys_on_destroy = false
}
}
}
data "azurerm_client_config" "current" {}
variable "location" {
type = string
default = "eastus2"
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-hsm-prod"
location = var.location
tags = { environment = "prod" }
}
# The security domain needs three certificates; the key-vault module owns them.
module "kv_security_domain" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault.git?ref=v1.0.0"
name = "kv-hsm-sd"
resource_group_name = module.rg.name
location = var.location
tenant_id = data.azurerm_client_config.current.tenant_id
# Three self-signed certificates, generated in the vault. The security domain is
# encrypted to these public keys, and the private halves are what let you restore
# the HSM -- so treat them as the HSM's root of recovery.
certificates = {
for k in ["sd1", "sd2", "sd3"] : k => {
certificate_policy = {
issuer_parameters = { name = "Self" }
key_properties = {
exportable = true
key_type = "RSA"
reuse_key = false
key_size = 2048
}
secret_properties = { content_type = "application/x-pkcs12" }
x509_certificate_properties = {
subject = "CN=hsm-security-domain-${k}"
validity_in_months = 12
}
}
}
}
}
module "managed_hsm" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module.git?ref=v1.0.0"
name = "hsm-prod-eastus2"
resource_group_name = module.rg.name
location = var.location
tenant_id = data.azurerm_client_config.current.tenant_id
admin_object_ids = [data.azurerm_client_config.current.object_id]
security_domain_key_vault_certificate_ids = values(module.kv_security_domain.certificate_ids)
security_domain_quorum = 2
tags = { environment = "prod" }
}
# Local RBAC -- the data-plane grant Azure RBAC cannot give.
module "crypto_user" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment.git?ref=v1.0.0"
managed_hsm_id = module.managed_hsm.id
name = "1e243909-064c-6ac3-84e9-1c8bf8d6ad22"
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
}
# THIS MODULE.
module "hsm_key" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key.git?ref=v1.0.0"
name = "key-storage-cmk"
managed_hsm_id = module.managed_hsm.id
key_type = "RSA-HSM"
key_size = 4096
key_opts = ["wrapKey", "unwrapKey"]
# No expiration_date on purpose -- the rotation policy below handles expiry,
# and setting it here would be irreversible.
tags = { environment = "prod" }
depends_on = [module.crypto_user]
}
# Rotation, as a separate resource -- there is no inline block on a Managed HSM key.
module "hsm_key_rotation" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-key-vault-managed-hardware-security-module-key-rotation-policy.git?ref=v1.0.0"
# The DATA-PLANE URI, which is this module's `id` -- not an ARM path.
managed_hsm_key_id = module.hsm_key.id
expire_after = "P90D"
time_before_expiry = "P30D"
}
# A consumer that SHOULD follow rotations, so it takes the unversioned uri.
module "disk_encryption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-disk-encryption-set.git?ref=v1.0.0"
name = "des-prod"
resource_group_name = module.rg.name
location = var.location
managed_hsm_key_id = module.hsm_key.id
auto_key_rotation_enabled = true
tags = { environment = "prod" }
}
output "posture" {
value = {
hsm = module.hsm_key.managed_hsm_name
operations = module.hsm_key.permitted_operations
weak_rsa = module.hsm_key.rsa_key_size_is_below_the_modern_2048_bit_floor
permits_nothing = module.hsm_key.key_permits_no_operations
no_public_key = module.hsm_key.no_public_key_material_is_emitted_by_this_resource
rotation_trigger = module.hsm_key_rotation.rotation_trigger
}
}🔴 Three things in this composition exist only because of the dual-plane model: the
azurerm_key_vault_managed_hardware_security_module_role_assignmentresource, thedepends_on, and the fact that neither is expressible as a module input.
💡
features {}appears here and never inside a module. The toggle shown decides whether destroying the key leaves it recoverable — the safe default isfalse, meaning soft delete.
⚠️ The disk encryption set takesid, notversioned_id, so it follows each rotation. Pinning it to a version would quietly freeze it at today's key.
| Input | Type | Default | Notes |
|---|---|---|---|
name |
string |
— | Required. Force-new. Rejects both an ARM ID and a URI. |
managed_hsm_id |
string |
— | Required. Force-new. ARM path, not hsm_uri. |
key_type |
string |
— | Required. Force-new. EC-HSM / oct-HSM / RSA-HSM. |
key_opts |
set(string) |
— | Required. Case-sensitive. Empty is allowed and reported. |
key_size |
number |
null |
Required for RSA/oct, forbidden for EC. Force-new. |
curve |
string |
null |
Required for EC, forbidden otherwise. Force-new. |
expiration_date |
string |
null |
Irreversible once set. Force-new only if removed. |
not_before_date |
string |
null |
Ordinary; editable and removable. |
tags |
map(string) |
{} |
In-place. |
timeouts |
object(...) |
null |
All four operations. |
Full schemas
variable "name" { type = string }
# Anchored, case-insensitive on managedHSMs; rejects hsm_uri and a Key Vault ID.
variable "managed_hsm_id" { type = string }
# Closed set -- every option is HSM-protected. No software RSA or EC exists here.
variable "key_type" { type = string }
# Near miss rejected, unknown allowed. A set, not a list.
variable "key_opts" { type = set(string) }
variable "key_size" {
type = number
default = null
}
variable "curve" {
type = string
default = null
}
# Carries the inverted-date cross-check, referencing not_before_date.
variable "expiration_date" {
type = string
default = null
}
variable "not_before_date" {
type = string
default = null
}
variable "tags" {
type = map(string)
default = {}
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}| Output | Type | Notes |
|---|---|---|
id |
string |
A data-plane URI, unversioned. What the rotation policy consumes. |
versioned_id |
string |
Version-pinned. Changes on rotation. |
name / managed_hsm_id / key_type / key_size / curve |
string/number |
Force-new. |
expiration_date / not_before_date |
string |
Or null. |
tags |
map(string) |
In-place. |
managed_hsm_name / managed_hsm_resource_group |
string |
Derived from the ARM ID. |
permitted_operations |
list(string) |
Derived, sorted. |
key_permits_no_operations |
bool |
Derived. Reported, not rejected. |
rsa_key_size_is_below_the_modern_2048_bit_floor |
bool |
Derived, key_type-aware. |
no_public_key_material_is_emitted_by_this_resource |
bool |
Always true. |
creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant |
bool |
Always true. |
expiration_date_can_never_be_unset_once_set |
bool |
Always true. |
destroy_behaviour_depends_on_a_provider_features_toggle_the_caller_owns |
bool |
Always true. |
🔒 Nothing is marked
sensitive, because there is nothing to mark. The provider emits no key material — seeno_public_key_material_is_emitted_by_this_resource.
A Managed HSM has two independent authorization systems, and the one that matters here is not Azure RBAC. Microsoft documents a dual-plane model: the control plane at management.azure.com is governed by Azure RBAC, and the data plane at <hsm-name>.managedhsm.azure.net is governed by Managed HSM local RBAC. Creating, reading, rotating or deleting a key is a data-plane operation, so Owner or Contributor on the Managed HSM resource confers no ability to create this key — Azure's own security baseline records "Azure RBAC for Data Plane: Supported — False" for the service. The identity running Terraform needs a local-RBAC assignment, typically Crypto User to create and delete and Crypto Officer to purge or rotate, at scope /keys or /keys/<key-name>.
That grant is also an ordering problem this module cannot express. The provider's own example wires depends_on from the key to two role-assignment resources, because a configuration creating the HSM and the key in one pass will otherwise attempt the key before the identity is permitted to. No input on this module can carry that dependency — the role assignment is a sibling resource owned by terraform-azurerm-key-vault-managed-hardware-security-module-role-assignment — so it belongs in the composition, ordered explicitly. depends_on is valid on a module block; it is lifecycle that is not.
The resource emits no key material whatsoever, not even the public half, and that is a categorical difference from azurerm_key_vault_key. A Key Vault key emits public_key_pem, public_key_openssh and the raw components e, n, x and y. A Managed HSM key emits none of them; its only cryptographic identifiers are the two URIs. So there is nothing here to mark sensitive and nothing to leak — a stronger position than redaction, since this suite's usual practice is to emit a public key unredacted and keep the private half out of Terraform, and here the provider has removed the question. The consequence is practical: if you need the public key to pin a certificate or hand to a partner, fetch it from the data plane with az keyvault key download, and expect any pipeline that assumes a key resource yields a PEM to need rewriting. It is also worth remembering what sensitive = true would have bought anyway — it redacts plan output and does not encrypt state.
The converse of all that is a pleasant one: plan access here is not credential access. Refreshing this resource requires data-plane read on the key, but since the provider returns no material, an identity granted plan rights learns the key's metadata and never its bytes. In much of this library the opposite holds, so the distinction is worth stating rather than assuming.
expiration_date is one of the few genuinely irreversible edits in this library. The provider states that once set it cannot be unset even if the key is deleted and recreated, because the underlying API restores the purged key rather than creating a fresh one. On top of that, both dates are force-new in one direction only — and the direction is removal, not direction of travel. The CustomizeDiff predicate is old value non-empty, new value empty, so deleting either date argument replaces the key, while moving a date is an ordinary in-place update whichever way it moves. The two facts compound: under the provider defaults the replacement soft-deletes and then recovers the key, which brings the old expiry back, so deleting the line churns the resource and does not clear the expiry. Only with purge_soft_deleted_hsm_keys_on_destroy enabled and HSM purge protection off is the key genuinely purged and regenerated. The remedy is to express expiry through the sibling rotation policy instead, as a duration applied to each newly rotated version.
Two ID conventions live in one family, and each module rejects the other's. This resource's managed_hsm_id is an ARM Resource ID; its own id is a data-plane URI, and the sibling rotation policy consumes that URI. The parent module emits both an ARM id and an hsm_uri, both legitimate somewhere, which is precisely why the wrong one is easy to pass — so managed_hsm_id rejects the URI form by name, and name rejects the URI form too. The managedHSMs segment is matched case-insensitively, because Azure returns it capitalised while other tooling lower-cases it and a case difference is not a real error.
Every key type is hardware-protected, which shifts where the risk sits. EC-HSM, oct-HSM and RSA-HSM are the only options; the plain RSA and EC types that Key Vault accepts do not exist, and passing one is the predictable mistake when adapting a Key Vault configuration. key_type also decides which sizing argument applies — curve for EC, key_size for RSA and oct — and this module rejects supplying the wrong one as contradictory rather than merely redundant. Both halves of that pairing are placed on the argument they constrain, because a validation may only reference its own variable, which means an error can name a field the caller was not editing; the messages say so.
Two facts are reported rather than enforced, for different reasons. An empty key_opts produces a key that permits nothing: the provider documents no minimum, so refusing it would invent a constraint, and the failure would otherwise surface only when an application tried to use the key. An RSA-HSM key below 2048 bits is below every current recommendation, but the provider's own documentation offers 1024 as an example key_size — so this module declines to override its source of truth on a published point and surfaces the fact instead. That flag is key_type-aware, so a 256-bit oct-HSM AES key correctly reads false. A documentation wrinkle sits underneath it: the provider describes key_size as bytes and then gives bit values as examples, and Azure reads it as bits.
Finally, what happens on destroy is not this module's decision. Whether the key is soft-deleted or purged depends on purge_soft_deleted_hardware_security_module_keys_on_destroy in the caller's features {} block, so the same module can leave a recoverable key in one configuration and permanently destroy it in another. A purged key is unrecoverable, and anything encrypted under it is unrecoverable with it.
| Concern | This module's position | Why |
|---|---|---|
| Data-plane RBAC | Named in a constant output and headlined in the RBAC table. | Azure RBAC cannot grant it; the apply fails otherwise. |
| The role-assignment ordering | Documented in prose and an example, not faked as an input. | It is a sibling resource this module does not own. |
| Key material | None emitted; nothing marked sensitive. | The provider exposes none — stronger than redaction. |
| Plan vs credential access | Explicitly stated as not equivalent here. | Elsewhere in this library it is. |
expiration_date |
Named in a constant output as irreversible. | It survives destroy-and-recreate. |
| Key type | Closed set, with the -HSM suffix named in the message. |
Adapting a Key Vault config is the predictable mistake. |
curve / key_size |
Required and forbidden checks, placed on each. | A validation may only reference its own variable. |
Empty key_opts |
Reported, not rejected. | The provider documents no minimum. |
| RSA below 2048 | Reported, not rejected. | The provider offers 1024 as an example. |
| The two ID forms | Each rejects the other by name. | Both are legitimate elsewhere in the family. |
| Destroy behaviour | Named as the caller's features {} decision. |
It is not visible from this module. |
tags |
Carried as the universal tail. | The resource exposes tags — unlike its rotation-policy sibling. |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 — never a branch. Plan-only; a human applies from CI.
Before the first apply — the grant Azure RBAC will not give you:
az keyvault role assignment create --hsm-name hsm-prod \
--role "Managed HSM Crypto User" --assignee <object-id> --scope /keys
# Confirm it landed.
az keyvault role assignment list --hsm-name hsm-prod --assignee <object-id> --scope /keysAfter the apply:
# Metadata, including the current version.
az keyvault key show --hsm-name hsm-prod --name key-storage-cmk
# The public key -- which Terraform will not give you.
az keyvault key download --hsm-name hsm-prod --name key-storage-cmk --file public.pemWhat the offline gate proves: that the configuration parses against the pinned provider, that formatting is canonical, and — via terraform console with variables files grouped by which variables each validation references — that all 17 validations fire on the input each targets. Grouping mattered here: key_size and curve both reference key_type, and expiration_date references not_before_date.
Suppression was demonstrated, not assumed. A file with a bad key_type and a negative key_size and the invalid curve P-512 produced exactly one error — every validation on key_size and curve was skipped because both reference the failed key_type. Re-running with a valid key_type then produced all of them. The same happened with the dates: a malformed not_before_date suppressed expiration_date's own shape check, so that check was only proved once not_before_date was left unset. A short error list is not proof a check is missing.
Proved with a failing value each: an empty name, a Resource ID in name, and the data-plane URI in name; hsm_uri in managed_hsm_id and a Key Vault ID in the same argument; key_type = "RSA"; key_opts containing wrapkey; key_size negative, fractional, missing for RSA-HSM, and present for EC-HSM; curve = "P-512", missing for EC-HSM, and present for RSA-HSM; both dates malformed; and a not_before_date a year after expiration_date.
The near-miss rule was proved in both directions in one file: wrapkey rejected while somethingNew passed.
Every derived value was printed rather than reasoned about, against all four key_type shapes. rsa_key_size_is_below_the_modern_2048_bit_floor read true for RSA-HSM/1024 and false for RSA-HSM/4096, EC-HSM and oct-HSM/256 — confirming the flag is key-type-aware rather than a bare numeric comparison. key_permits_no_operations read false with three operations and true with an empty set. permitted_operations came back sorted. The parsed HSM name, resource group and subscription were confirmed against two different Resource IDs.
What only an apply exercises: whether the HSM exists and is activated, and whether the identity holds the local-RBAC role — the most likely failure and one no offline check can reach.
What no Terraform run exercises at all: whether the key is actually usable by the applications that need it, and what the features {} toggle will do on a future destroy.
Outputs:
creating_this_key_requires_data_plane_rbac_that_azure_rbac_cannot_grant = true
curve = null
destroy_behaviour_depends_on_a_provider_features_toggle_the_caller_owns = true
expiration_date = null
expiration_date_can_never_be_unset_once_set = true
id = "https://hsm-prod-eastus2.managedhsm.azure.net/keys/key-storage-cmk"
key_permits_no_operations = false
key_size = 4096
key_type = "RSA-HSM"
managed_hsm_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hsm-prod/providers/Microsoft.KeyVault/managedHSMs/hsm-prod-eastus2"
managed_hsm_name = "hsm-prod-eastus2"
managed_hsm_resource_group = "rg-hsm-prod"
name = "key-storage-cmk"
no_public_key_material_is_emitted_by_this_resource = true
not_before_date = null
permitted_operations = [
"unwrapKey",
"wrapKey",
]
rsa_key_size_is_below_the_modern_2048_bit_floor = false
tags = {
"environment" = "prod"
}
versioned_id = "https://hsm-prod-eastus2.managedhsm.azure.net/keys/key-storage-cmk/9a8b7c6d5e4f3210"
| Symptom | Cause | Fix |
|---|---|---|
| Apply fails: forbidden / unauthorized on the key | The identity has Azure RBAC but no local RBAC. | az keyvault role assignment create --role "Managed HSM Crypto User" --scope /keys. |
| Apply fails on the first run, succeeds on the second | The key was created before the role assignment landed. | Add depends_on from the module to the role assignment. |
key_type must be one of EC-HSM, oct-HSM or RSA-HSM |
A Key Vault value such as RSA. |
Add the -HSM suffix. |
managed_hsm_id is a data-plane URI |
The parent's hsm_uri instead of its id. |
Pass module.managed_hsm.id. |
managed_hsm_id must be a full Managed HSM Resource ID |
A Key Vault ID, or a truncated path. | Use a managedHSMs ID; a vault key is azurerm_key_vault_key. |
key_size is required when key_type is RSA-HSM or oct-HSM |
Sizing argument missing. | Set key_size, or switch to EC-HSM with a curve. |
curve must not be set unless key_type is EC-HSM |
Both sizing arguments supplied. | Remove whichever the key type does not use. |
curve must be one of P-256, P-256K, P-384 or P-521 |
Usually P-512. |
Use P-521 — that curve is 521 bits. |
a key_opts entry differs ... only by case |
wrapkey or WrapKey. |
Use wrapKey / unwrapKey. |
expiration_date must be a UTC datetime |
A date alone, or a +00:00 offset. |
Use 2027-12-31T23:59:59Z. |
| An expiry will not go away | It cannot be unset, even via destroy. | Accept it, or use a new key; prefer a rotation policy next time. |
| Plan wants to replace the key after a date edit | A date argument was removed, not moved. CustomizeDiff force-news on old non-empty, new empty. |
Put the line back. Moving a date in either direction is in-place; only deleting it replaces the key, and the replacement will not clear the date anyway. |
| A tag edit produced a new key version | Every update to an HSM key creates a new version; the provider marks versioned_id newly-computed on a tags change. |
Expected. Reference id where a consumer should follow rotation, and keep frequently-edited metadata off the key. |
No public_key_pem output exists |
This resource emits no key material. | az keyvault key download. |
| A consumer stopped picking up rotations | It was wired to versioned_id. |
Point it at id instead. |
| A destroyed key vanished permanently | The caller's features {} purge toggle was on. |
Set it to false for soft delete. |
azurerm_key_vault_managed_hardware_security_module_keyprovider reference- Microsoft Learn — Access control for Managed HSM
- Microsoft Learn — Managed HSM local RBAC built-in roles
- Microsoft Learn — Secure your Managed HSM deployment
- Parent:
terraform-azurerm-key-vault-managed-hardware-security-module— suppliesmanaged_hsm_idfrom itsidoutput, and also emitshsm_uri, which is not what this module wants. - Sibling:
terraform-azurerm-key-vault-managed-hardware-security-module-key-rotation-policy— consumes this module'sid(the data-plane URI). At most one per key, where an HSM holds many keys. - Related:
terraform-azurerm-key-vault— suppliescertificate_idsfor the HSM's security domain, and ownsazurerm_key_vault_keyfor software- or vault-HSM-protected keys. - Consumer:
terraform-azurerm-disk-encryption-set— has amanaged_hsm_key_idinput; wire it toidso it follows rotations. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."