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 (roughly $3–6 for a short sitting on a small cluster). Do it in one sitting and run the teardown at the end — the final step deletes everything. Set a budget alert first if you're cost-nervous; the cost-guardrails lab shows how.
You need a paid or free-trial Azure subscription, the Azure CLI (az) with the aks-preview features enabled, 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's 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_B2s \ --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'll 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. The mutating webhook injects the token path and environment; your code (or, here, the Key Vault CSI/SDK) exchanges that token for access and pulls the secret at runtime. No Secret object, no env-var password.
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 --identity && 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
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 a single explicit allow for the one connection the app legitimately needs. Everything unlisted is now refused.
# deny all ingress and egress in the namespace, then allow frontend -> backend:3000 only
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-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels: { app: backend }
ingress:
- from:
- podSelector:
matchLabels: { app: frontend }
ports:
- port: 3000
EOF
Prove it. Stand up a throwaway pod with no allowed path and try to reach the backend — it should hang and fail, not connect.
kubectl run probe -n production --image=busybox --restart=Never -- \
sh -c "wget -T 5 -qO- backend:3000 || echo BLOCKED"
kubectl logs -n production probe # -> BLOCKED (timed out, as designed)
BLOCKED. An unlisted pod cannot reach the backend, while the sanctioned frontend→backend path stays open. 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's gone. One resource-group delete removes the AKS nodes, the identity, and the vault together. Do this now, not tomorrow.
az group delete -n campux-zt-rg --yes --no-wait
az group exists -n campux-zt-rg # -> false, once the async delete completes
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 a single explicit allow. 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're 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's 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.