In a properly segmented landing zone, certificates are centralized. In one platform I ran, the wildcard cert lived in a Key Vault in the Identity subscription, the Application Gateway that terminated TLS lived in the Connectivity subscription, and the workloads behind it sat in a dozen spoke subscriptions. Clean design — until you open the portal to attach the cert to the gateway’s HTTPS listener and discover the Key Vault picker only lists vaults in the gateway’s own subscription. The cross-subscription cert simply isn’t selectable.
This is a portal limitation, not a platform one. The wiring works perfectly cross-subscription — you just have to do it through the CLI, ARM, Bicep, or Terraform. Here’s the pattern, end to end.
The mechanism: a user-assigned managed identity
Application Gateway reads a Key Vault certificate as a managed identity, and for Key Vault it must be a user-assigned identity (system-assigned isn’t supported for this integration). The identity needs GET on secrets in the vault — a certificate is exposed to the gateway through its secret representation. Subscriptions don’t matter to RBAC inside one Entra tenant, so a role assignment scoped to the vault in the Identity subscription is all it takes.
Three moving parts:
- A user-assigned managed identity (UAMI) attached to the App Gateway.
- An RBAC role assignment (
Key Vault Secrets User) giving that UAMI GET on the central vault. - The listener’s SSL cert referenced by its unversioned Key Vault secret ID (so renewals auto-rotate — the gateway polls Key Vault roughly every 4 hours).
Step 1 — Create the identity and grant it on the central vault
# In the Connectivity subscription
az account set --subscription "$CONNECTIVITY_SUB"
az identity create -g rg-connectivity -n id-appgw-kv
UAMI_ID=$(az identity show -g rg-connectivity -n id-appgw-kv --query id -o tsv)
UAMI_PRINCIPAL=$(az identity show -g rg-connectivity -n id-appgw-kv --query principalId -o tsv)
# Grant GET on the central vault, which lives in the Identity subscription.
KV_ID=$(az keyvault show --subscription "$IDENTITY_SUB" -n kv-central-certs --query id -o tsv)
az role assignment create \
--assignee-object-id "$UAMI_PRINCIPAL" \
--assignee-principal-type ServicePrincipal \
--role "Key Vault Secrets User" \
--scope "$KV_ID"
If the vault still uses access policies instead of RBAC, grant it there instead:
az keyvault set-policy --subscription "$IDENTITY_SUB" -n kv-central-certs --object-id "$UAMI_PRINCIPAL" --secret-permissions get.
Step 2 — Attach the identity and bind the certificate (CLI)
# Attach the UAMI to the gateway
az network application-gateway identity assign \
-g rg-connectivity --gateway-name agw-hub --identity "$UAMI_ID"
# Reference the cert by its UNVERSIONED secret id (note: /secrets/, not /certificates/)
SECRET_ID=$(az keyvault secret show --subscription "$IDENTITY_SUB" \
--vault-name kv-central-certs -n wildcard-contoso \
--query id -o tsv | sed 's#/[^/]*$##') # strip the version for auto-rotation
az network application-gateway ssl-cert create \
-g rg-connectivity --gateway-name agw-hub \
-n wildcard-contoso --key-vault-secret-id "$SECRET_ID"
# Point the HTTPS listener at it
az network application-gateway http-listener update \
-g rg-connectivity --gateway-name agw-hub -n https-listener \
--ssl-cert wildcard-contoso
That’s the whole fix. The gateway now serves the central cert and re-pulls automatically when it’s renewed in the Identity subscription.
The same thing in Terraform (the version you actually commit)
resource "azurerm_user_assigned_identity" "appgw" {
name = "id-appgw-kv"
resource_group_name = azurerm_resource_group.connectivity.name
location = var.location
}
# Cross-subscription role assignment: provider aliased to the Identity subscription
resource "azurerm_role_assignment" "kv_get" {
provider = azurerm.identity
scope = data.azurerm_key_vault.central.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.appgw.principal_id
}
resource "azurerm_application_gateway" "hub" {
name = "agw-hub"
resource_group_name = azurerm_resource_group.connectivity.name
location = var.location
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.appgw.id]
}
ssl_certificate {
name = "wildcard-contoso"
key_vault_secret_id = data.azurerm_key_vault_certificate.wildcard.secret_id # unversioned
}
# ... gateway_ip_configuration, frontend_*, http_listener referencing the ssl_certificate ...
depends_on = [azurerm_role_assignment.kv_get]
}
The provider = azurerm.identity alias is the crux: the identity and gateway are created in
Connectivity, but the role assignment is created in the Identity subscription where the vault
lives. Configure both providers:
provider "azurerm" { features {} subscription_id = var.connectivity_sub }
provider "azurerm" { features {} alias = "identity" subscription_id = var.identity_sub }
Don’t forget the network path
RBAC lets the gateway authenticate to Key Vault; the network still has to reach it. If the central vault has its firewall enabled (it should), either:
- put a Private Endpoint for the vault in a subnet the App Gateway can route to (via the hub), or
- if using service firewall rules, allow the gateway’s outbound path and “Allow trusted Microsoft services.”
A gateway that can authenticate but not connect shows the cert state as Unknown — check Application Gateway → Backend health / TLS and the vault’s networking blade first.
Enterprise scenario
A retail platform team I worked with had exactly this topology, plus a twist that broke them in production six weeks after go-live: the gateway started serving an expired wildcard, even though the central vault held a freshly renewed cert. The renewal had worked; the gateway just never picked it up. The root cause was the SSL cert had been bound by its versioned secret ID — someone had copied the full URL out of the portal, version GUID and all. App Gateway only auto-rotates when the reference is unversioned; a pinned version is frozen forever. The renewal landed under a new version, the old one expired, and the polling loop dutifully kept fetching the dead version.
The fix was to re-bind against the unversioned secret ID. The trap is that
az keyvault secret show --query id returns the versioned URL, so you must strip the trailing
segment yourself before handing it to the gateway:
RAW=$(az keyvault secret show --subscription "$IDENTITY_SUB" \
--vault-name kv-central-certs -n wildcard-contoso --query id -o tsv)
echo "$RAW" # .../secrets/wildcard-contoso/8a1b... <- versioned, DON'T use
SECRET_ID="${RAW%/*}" # .../secrets/wildcard-contoso <- unversioned, USE THIS
az network application-gateway ssl-cert update \
-g rg-connectivity --gateway-name agw-hub \
-n wildcard-contoso --key-vault-secret-id "$SECRET_ID"
We then added a guardrail in CI: a conftest/OPA policy that fails any plan whose
key_vault_secret_id matches a trailing version GUID. One ambiguous portal URL had silently
defeated the entire auto-rotation design — exactly the failure mode centralized certs are supposed to
prevent.
Verify
# Cert should be in 'Provisioned' / no errors
az network application-gateway ssl-cert show -g rg-connectivity \
--gateway-name agw-hub -n wildcard-contoso -o jsonc
# From a client
curl -vI https://app.contoso.com 2>&1 | grep -Ei "issuer|expire|subject"
Checklist
Why this pattern matters
Centralizing certificates in one vault is the right call — one place to rotate, audit, and govern. The cross-subscription portal gap is exactly the kind of thing that makes teams give up and scatter certs into per-subscription vaults, quietly destroying the governance they designed. Don’t. The identity-plus-IaC pattern above keeps the central vault and every gateway working — and it generalizes: the same UAMI-with-KV-GET approach wires central certs into App Service, API Management, and Front Door across subscriptions too.