Federate a ServiceAccount to a Managed Identity, watch a pod read Key Vault with no secret, then break a connection with a network policy on purpose.
Every static secret is a liability with a timer on it
A Kubernetes Secret feels like security, but it is just base64 sitting in etcd, copied into pods, and printed into anyone's terminal who runs the wrong kubectl. The moment it exists, it can leak; once leaked, rotating it is a scramble across every place it was pasted. Workload Identity removes the object entirely: the pod presents a short-lived token that Azure trusts because you federated its identity in advance, and Azure hands back exactly the access you granted — no long-lived credential ever touches the cluster.
You will enable that on AKS, tie a Kubernetes ServiceAccount to an Azure Managed Identity, and watch a pod read a Key Vault secret with nothing but its own identity. Then you will close the other half of zero trust — the network — by defaulting the namespace to deny all and allowing only the one path the app actually needs.
Identity you can federate; secrets you can only hope to rotate.
Unlike the free-tier labs, an AKS cluster runs real nodes and bills by the hour. The AKS control plane itself is free on the default Free tier — you are paying for the two Standard_D2s_v3 nodes, which list at roughly $0.10/hour each in East US, plus a few cents an hour for the load balancer and disks. A few hours of hands-on work runs roughly $1–2, not more, as long as you tear down the same day. Do it in one sitting and run the teardown at the end — the final step deletes everything. Set a budget alert first if you are cost-nervous; the cost-guardrails lab shows how.
You need a paid or free-trial Azure subscription where you hold Contributor (or Owner) — enough to create the cluster, the identity, and the vault, and to set the vault's access policy; a role that can only assign RBAC roles is not enough here, since this vault uses access policies, not Azure RBAC. You also need the Azure CLI (az, version 2.47 or later — OIDC issuer and Workload Identity are GA features and need no preview extension) and kubectl. New to the tools? The Set up your machine page covers installs and az login; everything here also runs in Azure Cloud Shell (Bash), which has az and kubectl ready. This build assumes you have met AKS and kubectl before — if not, that is the prerequisite, not this page.
A cluster that can issue tokens
Create a resource group and a small AKS cluster with two capabilities most clusters ship without: an OIDC issuer (so Kubernetes can mint verifiable tokens) and Workload Identity (so Azure will accept them). Capture the issuer URL and your tenant id — you will need both to federate.
# Windows/Git Bash: stop it mangling resource-id arguments (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 RG="campux-zt-rg" az group create -n "$RG" -l eastus az aks create -g "$RG" -n campux-zt-aks \ --node-count 2 --node-vm-size Standard_D2s_v3 \ --enable-oidc-issuer --enable-workload-identity \ --network-plugin azure --network-policy azure \ --generate-ssh-keys az aks get-credentials -g "$RG" -n campux-zt-aks kubectl create namespace production # capture the two values federation needs export OIDC_ISSUER=$(az aks show -g "$RG" -n campux-zt-aks --query "oidcIssuerProfile.issuerUrl" -o tsv) export TENANT_ID=$(az account show --query tenantId -o tsv) echo "issuer: $OIDC_ISSUER"
kubectl get nodes shows two Ready nodes, and $OIDC_ISSUER holds a URL. The --network-policy azure flag matters — it installs the policy engine you will rely on in the last step, and it cannot be added after the cluster exists.An identity, and a secret only it may read
Create a user-assigned Managed Identity — the Azure-side identity the pod will borrow — and a Key Vault holding one secret. Grant the identity read access to that secret and nothing else. This is least privilege written as configuration.
az identity create -g "$RG" -n app-workload-id
export CLIENT_ID=$(az identity show -g "$RG" -n app-workload-id --query clientId -o tsv)
az keyvault create -g "$RG" -n kv-campux-zt --enable-rbac-authorization false
az keyvault secret set --vault-name kv-campux-zt --name db-password --value "SecureP@ssword123"
# the identity may read secrets — get/list only
az keyvault set-policy -n kv-campux-zt --spn "$CLIENT_ID" --secret-permissions get list
db-password, and the identity's clientId is in $CLIENT_ID. Nothing yet connects the two worlds — the pod cannot use this identity until you federate it, which is the next step and the crux of the whole build.Federate the ServiceAccount
This is the join. A Kubernetes ServiceAccount, annotated with the identity's client id, is what pods run as. A federated credential tells Azure: "trust tokens this specific issuer mints for this specific ServiceAccount, and treat them as this Managed Identity." Subject and issuer must match exactly, or Azure rejects the token — most first-attempt failures are a typo here.
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
namespace: production
annotations:
azure.workload.identity/client-id: "$CLIENT_ID"
EOF
az identity federated-credential create \
--name app-fed-credential \
--identity-name app-workload-id \
--resource-group "$RG" \
--issuer "$OIDC_ISSUER" \
--subject "system:serviceaccount:production:app-sa"
az identity federated-credential list --identity-name app-workload-id -g "$RG" -o table) and its subject reads system:serviceaccount:production:app-sa. That string is a contract: change the namespace or ServiceAccount name later and you must re-federate.Deploy the workload — watch it read a secret it never stored
Deploy a pod that runs as app-sa and carries the azure.workload.identity/use label. Because the pod matches that label and that ServiceAccount, the workload identity mutating webhook injects four environment variables at admission — AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_AUTHORITY_HOST, and AZURE_FEDERATED_TOKEN_FILE (a path to a token Kubernetes rotates on disk) — no code in the pod spec asks for them. Your code (or, here, the Azure CLI) reads that token file and exchanges it for an Azure access token at runtime. No Secret object, no env-var password.
az login --identity talks to the Azure Instance Metadata Service — it signs in as the node's identity, not the federated one, and a pod cannot reach IMDS the way a VM can. Workload identity needs the federated-token exchange instead: az login --service-principal ... --federated-token ..., reading the token from the file path the webhook already gave you.
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app
namespace: production
spec:
replicas: 1
selector:
matchLabels: { app: secure-app }
template:
metadata:
labels:
app: secure-app
azure.workload.identity/use: "true"
spec:
serviceAccountName: app-sa
containers:
- name: app
image: mcr.microsoft.com/azure-cli:latest
command: ["/bin/sh","-c","az login --service-principal --username \$AZURE_CLIENT_ID --tenant \$AZURE_TENANT_ID --federated-token \$(cat \$AZURE_FEDERATED_TOKEN_FILE) && az keyvault secret show --vault-name kv-campux-zt --name db-password --query value -o tsv && sleep 3600"]
EOF
# the secret value should appear in the logs — fetched with no stored credential
kubectl logs -n production deploy/secure-app
az login error, check that the ServiceAccount's azure.workload.identity/client-id annotation and the pod's azure.workload.identity/use: "true" label are both exactly as written above — the webhook only injects the four environment variables when both match.Default-deny the network, then allow exactly one path
Identity is half of zero trust; the network is the other half. By default Kubernetes lets every pod talk to every pod — the flat network an attacker loves. Apply a default-deny policy to the namespace, then explicit allows for the one connection the app legitimately needs, and nothing else. Everything unlisted is now refused.
First, something to point the policy at. An allow rule naming backend only means something if a backend actually exists — deploy one, small and real, with a Service in front of it so the name backend resolves.
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: production
spec:
replicas: 1
selector:
matchLabels: { app: backend }
template:
metadata:
labels: { app: backend }
spec:
containers:
- name: backend
image: registry.k8s.io/e2e-test-images/agnhost:2.40
args: ["serve-hostname", "--http", "--port=3000"]
ports:
- containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: production
spec:
selector: { app: backend }
ports:
- port: 3000
targetPort: 3000
EOF
Now the policies. Default-deny covers both directions for every pod in the namespace, which also blocks DNS — a namespace-wide deny still has to let every pod resolve names, so an explicit DNS allow comes first. A Kubernetes NetworkPolicy requires both sides to agree: the source pod's egress and the destination pod's ingress must each allow the connection, or it does not happen — so the frontend→backend allow needs an egress rule on the frontend and an ingress rule on the backend, not just one.
# deny all ingress/egress in the namespace, then allow DNS, then allow frontend -> backend:3000 both ways
kubectl apply -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: production
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend-ingress
namespace: production
spec:
podSelector:
matchLabels: { app: backend }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: frontend }
ports:
- port: 3000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend-egress
namespace: production
spec:
podSelector:
matchLabels: { app: frontend }
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: { app: backend }
ports:
- port: 3000
EOF
Prove both halves. A pod with no frontend label should hang and fail; a pod labelled frontend should connect.
# no label — the default-deny wins kubectl run probe-blocked -n production --image=busybox --restart=Never -- \ sh -c "wget -T 5 -qO- backend:3000 || echo BLOCKED" kubectl logs -n production probe-blocked # -> BLOCKED (timed out, as designed) # labelled frontend — the explicit allow lets it through kubectl run probe-allowed -n production --image=busybox --restart=Never --labels="app=frontend" -- \ sh -c "wget -T 5 -qO- backend:3000 && echo ALLOWED" kubectl logs -n production probe-allowed # -> the backend's hostname, then ALLOWED
probe-blocked reports BLOCKED; probe-allowed reports ALLOWED. An unlisted pod cannot reach the backend, while the one sanctioned frontend→backend path stays open — both proven, not just claimed. You've turned "trust nothing by default" from a slogan into an enforced policy — the exact control auditors ask to see.Tear it down
The cluster bills until it is gone. One resource-group delete removes the identity and the vault, and deletes the AKS cluster resource itself — which in turn deletes the second resource group AKS created for you (named MC_campux-zt-rg_campux-zt-aks_eastus) along with it, taking the actual node VMs, disks, and load balancer with it. You never delete that second group yourself; Azure does it as part of deleting the cluster. Do this now, not tomorrow.
az group delete -n campux-zt-rg --yes --no-wait # right after --no-wait this still says true — the delete just started; recheck in a few minutes az group exists -n campux-zt-rg # -> false, once the async delete completes az group exists -n MC_campux-zt-rg_campux-zt-aks_eastus # -> false too, once it's done
false once the delete finishes — check the Azure portal's resource-group list if you want to watch it happen rather than poll. Key Vault has soft-delete on by default — if you plan to reuse the name kv-campux-zt soon, purge it with az keyvault purge -n kv-campux-zt, otherwise the name is reserved for the retention window.What you can now honestly claim
You ran a workload on AKS with zero static secrets, federating a Kubernetes ServiceAccount to an Azure Managed Identity so a pod read Key Vault with nothing but a short-lived token, and you enforced default-deny network policies with explicit allows for exactly the one connection the app needs. That is "implemented zero-trust workload identity and network segmentation on Kubernetes" — a senior line on any cloud-security posting — done, not described. The pattern transfers straight to GitHub Actions federating into Azure, to app-to-database access, and to any place you are tempted to paste a key: the durable lesson is that the safest secret is the one that never existed.
- The demo container here logs the secret to prove retrieval — a teaching move, never a production one. In a real app the SDK reads the secret into memory and it is never printed. Treat a secret in a log as an incident, even in a lab.
- Key Vault is used in its access-policy mode for brevity (
--enable-rbac-authorization false). The RBAC model — aKey Vault Secrets Userrole assignment on the identity — is the modern default and worth doing next; it is the same idea expressed as Azure RBAC rather than a vault-local policy. - Network policy is only enforced if the cluster has an engine for it, which is why the cluster was created with
--network-policy azure. On a cluster without one, the same manifests apply cleanly and do nothing — a silent failure worth knowing about.