Skip to content
CAMPUX Cloud Bootcamp Advanced Build · Zero-Trust ← All labs
Advanced Build · Expert
~5–6 hrs · Costs real money (AKS nodes)
Azure CLI & kubectl · torn down at the end
Security · Zero Trust

A workload with no secrets to steal.

The fastest way to leak a credential is to have one. In this build you run a real application on AKS that holds no password, no connection string, no key of its own — it proves who it is to Azure with a federated token, reads what it needs from Key Vault just-in-time, and sits inside a namespace that denies every packet it did not explicitly invite. This is what "zero trust" means once you stop saying it and start deploying it.

Fig. 1 · Zero-trust AKS: no static secret
Resource group · campux-zt-rgtokenread · least privilegeAKS workloadfederated ServiceAccountFederated identitytrusts the podKey Vaultone secret it may readevery static secret is a liability with a timer
● Screen walkthrough Not yet recorded · ~12 min
Reel · 00:00 / 12:00

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.

Placeholder — the page below stands alone until the reel lands
Why

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.

This one costs money — read before you start

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.

Before you begin — one-time setup

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.

Setup

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"
Checkpoint 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.
Step 1

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
Checkpoint The vault holds 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.
Step 2

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"
Checkpoint The federated credential exists (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.
Step 3

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 is the wrong flag here

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
Checkpoint The pod logs print the secret value, retrieved live from Key Vault by an identity the pod proved at runtime. Nowhere in the cluster is that password stored. Delete the pod and it takes no credential with it — there was never one to take. If the logs instead show an 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.
Step 4

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
Checkpoint 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.
Down

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
Checkpoint Both groups report 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.
End

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.

Footnotes
  1. 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.
  2. Key Vault is used in its access-policy mode for brevity (--enable-rbac-authorization false). The RBAC model — a Key Vault Secrets User role 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.
  3. 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.