AKS Security Hardening: Workload Identity, Pod Security, and Defender

Yesterday I covered building a production AKS cluster. Today we lock it down. Kubernetes security is layered by nature, and on AKS the layers are: how the API server is exposed, how pods get Azure credentials, what pods are allowed to do, what images are allowed to run, and what watches everything at runtime.

Private API Server and Entra Integration

The API server should not be on the internet, and local Kubernetes accounts should not exist. Enable the private cluster mode (or at minimum API server VNet integration with authorized ranges), turn on Entra ID integration with Azure RBAC, and disable local accounts so kubectl access always maps to a real identity with conditional access applied.

resource "azurerm_kubernetes_cluster" "this" {
  # ... base config from the previous post

  private_cluster_enabled = true
  local_account_disabled  = true

  azure_active_directory_role_based_access_control {
    azure_rbac_enabled = true
    tenant_id          = data.azurerm_client_config.current.tenant_id
  }

  workload_identity_enabled = true
  oidc_issuer_enabled       = true
}

Workload Identity: Kill Your Secrets

The single highest value change on most clusters is removing static credentials from pods. Entra Workload Identity federates a Kubernetes service account with a managed identity: the pod exchanges its projected service account token for an Entra token, no client secret anywhere, nothing to rotate or leak.

resource "azurerm_user_assigned_identity" "payments" {
  name                = "id-payments"
  resource_group_name = azurerm_resource_group.aks.name
  location            = "westeurope"
}

resource "azurerm_federated_identity_credential" "payments" {
  name                = "fic-payments"
  resource_group_name = azurerm_resource_group.aks.name
  parent_id           = azurerm_user_assigned_identity.payments.id
  audience            = ["api://AzureADTokenExchange"]
  issuer              = azurerm_kubernetes_cluster.this.oidc_issuer_url
  subject             = "system:serviceaccount:payments:payments-api"
}

resource "azurerm_role_assignment" "payments_kv" {
  scope                = azurerm_key_vault.payments.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_user_assigned_identity.payments.principal_id
}

On the Kubernetes side the service account carries the client ID annotation and the pod gets the azure.workload.identity/use label. The Azure SDKs pick everything up through DefaultAzureCredential with zero code changes.

Pod Security and Image Policy

Enforce the restricted Pod Security Standard on application namespaces: no privileged containers, no host network, non root, read only root filesystem, all capabilities dropped. A namespace label is all it takes with Pod Security Admission. For anything finer grained, AKS ships Azure Policy for Kubernetes on top of Gatekeeper, which also covers image control: allow pulls only from your Azure Container Registry, require digests instead of mutable tags, and block the latest tag outright. Combine that with Defender vulnerability scanning in ACR so images with critical CVEs never reach the cluster in the first place.

apiVersion: v1
kind: Namespace
metadata:
  name: payments
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/warn: restricted

Runtime: Defender for Containers

Defender for Containers deploys a sensor DaemonSet that watches for the things static policy cannot: crypto miners, reverse shells, suspicious exec into containers, access to the cloud metadata endpoint from unexpected pods. Enable it at the subscription level, route alerts into your SIEM, and actually rehearse the response to the top five alert types. Detection without a practiced response is just logging with better marketing.

Network policy deserves its own mention: default deny ingress and egress per namespace, then allow only declared flows. With the Cilium data plane from yesterday’s cluster you get that with standard NetworkPolicy resources at very low overhead. Layered together, these controls mean a compromised pod holds no static credentials, cannot escalate, cannot reach its neighbors, and trips an alarm when it tries.

Cheers
Osama

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.