- Published on
How Azure Workload Identity works on AKS — OIDC tokens, no client secrets
- Authors

- Name
- Krzysztof Kozłowski
For a long time, I shipped Azure apps with a Service Principal and a client secret stuffed into a Kubernetes Secret.
It worked. The pod started. The SDK called Key Vault. Life went on.
Then I tried to rotate that secret. Then I tried to figure out who could read it. Then I tried to track which pods still had the old version baked into their env vars.
That's when I started looking at Workload Identity, and that's where I hit a wall in the docs. Every guide jumps straight into Terraform and YAML before explaining what's actually happening.
So before any code, let me walk through the mental model. The setup is much simpler once you can picture it.
The core idea
With a Service Principal, the cluster owns a password. Any pod that wants to talk to Azure either gets that password handed to it, or it shares one with everyone else. The cluster is a credential warehouse.
With Workload Identity, the pod proves who it is instead.
There's no shared password. There's no API key sitting in a Secret. The pod presents a token signed by AKS itself, and Azure decides — based on rules you set up — whether to give that token access.
If an attacker walks away with a copy of your cluster, they walk away with zero usable Azure credentials. Because there weren't any.
The four pieces
Workload Identity is four small things working together, each with one job:
1. The OIDC issuer. AKS provides a public endpoint where it signs and serves tokens for its ServiceAccounts. Anyone can fetch the public keys and verify a token came from this cluster.
2. The ServiceAccount. Every pod runs as some SA. With Workload Identity, AKS puts a signed OIDC token into the pod's filesystem (/var/run/secrets/azure/tokens/azure-identity-token). Your code never asks for it — it's just there.
3. The Managed Identity (UAMI). A real Azure identity, with its own GUID and its own RBAC grants. You give all the access your app needs — Key Vault, Storage, Service Bus — to this identity.
4. The Federated Credential. A rule attached to the Managed Identity that says: "trust tokens from THIS issuer (your AKS cluster), for THIS subject (this exact namespace + ServiceAccount)."
That's it. Four pieces. Three of them you set up once. One of them — the pod label — is the only thing you touch when adding a new app.
What happens at runtime
When your pod calls Azure, this is the dance:
Three round trips, all hidden by the Azure SDK. From your app's point of view:
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
var client = new SecretClient(
new Uri("https://my-kv.vault.azure.net/"),
new DefaultAzureCredential()
);
var secret = await client.GetSecretAsync("db-password");
That's the whole thing. DefaultAzureCredential notices it's running in a Workload Identity environment and does the token exchange automatically. No client ID in env vars. No client secret anywhere.
The minimal setup
You need four things to make this work. None of them is more than a few lines.
1. Enable OIDC on AKS
Two Terraform flags on the cluster. Easy to miss because the defaults are off.
resource "azurerm_kubernetes_cluster" "this" {
# ... rest of your cluster config
oidc_issuer_enabled = true
workload_identity_enabled = true
}
After apply, azurerm_kubernetes_cluster.this.oidc_issuer_url is the public OIDC URL. You'll need it in step 3.
2. The Managed Identity
This is what your pod becomes, from Azure's perspective.
resource "azurerm_user_assigned_identity" "app" {
name = "id-app"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
}
resource "azurerm_role_assignment" "app_kv_reader" {
scope = azurerm_key_vault.this.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.app.principal_id
}
One identity per app. Don't share a "cluster identity" across services — you lose the whole point of fine-grained access.
3. The Federated Credential
The part that actually connects everything together.
resource "azurerm_federated_identity_credential" "app" {
name = "fc-app"
resource_group_name = azurerm_resource_group.this.name
parent_id = azurerm_user_assigned_identity.app.id
audience = ["api://AzureADTokenExchange"]
issuer = azurerm_kubernetes_cluster.this.oidc_issuer_url
subject = "system:serviceaccount:app:app-sa"
}
The subject format is strict. It has to be exactly:
system:serviceaccount:<namespace>:<serviceaccount-name>
One character off and the token exchange silently returns "unauthorized." This is the thing that bites everyone the first time. Make it a Terraform variable and never hand-write it.
4. The pod
Two small additions: a ServiceAccount with the right annotation, a Deployment with the right label.
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
namespace: app
annotations:
azure.workload.identity/client-id: <client-id-of-the-UAMI>
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
namespace: app
spec:
template:
metadata:
labels:
app: app
azure.workload.identity/use: 'true' # the magic label
spec:
serviceAccountName: app-sa
containers:
- name: app
image: ghcr.io/example/app:1.0.0
# no client secret env vars
The label is on the pod, not the Deployment metadata. The webhook only reads pod labels. I put it in the wrong place once and lost 30 minutes wondering why I wasn't getting a token.
Before and after
Same app, before and after the switch.
Before — Service Principal with a client secret in env vars:
env:
- name: AZURE_TENANT_ID
valueFrom:
secretKeyRef:
name: sp-credentials
key: tenant-id
- name: AZURE_CLIENT_ID
valueFrom:
secretKeyRef:
name: sp-credentials
key: client-id
- name: AZURE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: sp-credentials
key: client-secret
After — one label, zero env vars:
metadata:
labels:
azure.workload.identity/use: 'true'
spec:
serviceAccountName: app-sa
That sp-credentials Secret is gone. The rotation runbook is gone. The "where did we last rotate this" Slack search is gone.
The one trap
The subject field on the federated credential is case-sensitive and has to match the ServiceAccount exactly. If your namespace is app-prod and your SA is app-sa, the subject must be system:serviceaccount:app-prod:app-sa. Lowercase, exact.
Azure won't tell you what's wrong. The token exchange just returns "unauthorized" and you'll spend 30 minutes thinking your Key Vault permissions are off.
Make the subject a Terraform variable once, and never write it by hand again.
Key takeaways
- Workload Identity replaces a shared password with a per-pod identity proof. The cluster never holds an Azure credential it can leak.
- The flow has four pieces: OIDC issuer (AKS), ServiceAccount (Kubernetes), Federated Credential (Azure rule), Managed Identity (Azure principal). Each has one job.
- No code change to your app.
DefaultAzureCredentialhandles the token exchange automatically. - The federated credential
subjectfield is the single most common silent failure. Make it a Terraform variable.
What's next
This is the core idea. The natural next post is External Secrets Operator — it lets your app pull values directly from Key Vault using the same Workload Identity, so even the K8s Secret object disappears. I'll write about that next.
If you've hit a specific snag setting this up, ping me on LinkedIn — I'll cover it.