Architecture Azure

Enterprise Pattern: Binding a Cross-Subscription Key Vault Certificate to Application Gateway

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.

Cross-subscription Key Vault to Application Gateway

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:

  1. A user-assigned managed identity (UAMI) attached to the App Gateway.
  2. An RBAC role assignment (Key Vault Secrets User) giving that UAMI GET on the central vault.
  3. 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:

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.

Enterprise ScenarioKey VaultApplication GatewayManaged IdentityCross-SubscriptionCertificates

Comments

Keep Reading