This guide configures Microsoft Entra ID workload identity federation so that pods on your self-hosted Kubernetes cluster can authenticate to Azure using their Kubernetes ServiceAccount tokens — no client secrets, no certificates, no static credentials.
cwii (Cluster Workload Identity Injector) is a Rust mutating admission webhook. When a pod is annotated for Azure, cwii injects a projected ServiceAccount token and the environment variables that the Azure Identity SDKs read to perform an Entra ID federated identity credential token exchange.
!!! abstract “How the trust works”
1. The kube-apiserver signs a projected ServiceAccount token (an OIDC JWT) for the pod with the
audience api://AzureADTokenExchange.
2. cwii mounts that token at /var/run/secrets/cwii.dev/az/token and sets
AZURE_FEDERATED_TOKEN_FILE to point at it.
3. The Azure SDK presents the token to Entra ID. Entra ID validates it against the federated
identity credential you registered (matching issuer, subject and audience), then returns an
Azure access token for the app registration or user-assigned managed identity.
Before you start, confirm the following.
| Requirement | Detail |
|---|---|
| Cluster OIDC issuer published | The kube-apiserver must serve a public HTTPS OIDC discovery document and JWKS that Entra ID can fetch. See Self-hosted OIDC setup. |
| cwii installed | The webhook is running in the cwii-system namespace with Azure enabled (--az-enabled defaults to true). See Install. |
az CLI |
Azure CLI logged in with permission to create app registrations / managed identities and assign Azure RBAC roles. |
kubectl |
Configured against the target cluster. |
!!! warning “The issuer is the hard prerequisite”
Entra ID fetches your cluster’s JWKS over the public internet to verify token signatures. The
value you pass to the kube-apiserver flag --service-account-issuer (the iss claim) must
be reachable and must match byte-for-byte the issuer you register in the federated identity
credential below. If you have not completed Self-hosted OIDC setup,
stop here.
Record these values — you will reuse them throughout:
# The HTTPS URL configured as --service-account-issuer on your kube-apiserver.
export ISSUER_URL="https://oidc.example.com/my-cluster"
# The Kubernetes namespace and ServiceAccount your workload runs as.
export K8S_NAMESPACE="apps"
export K8S_SA="reports"
# Entra subject claim — see the gotcha below; this format is mandatory.
export SUBJECT="system:serviceaccount:${K8S_NAMESPACE}:${K8S_SA}"
# The audience cwii requests for Azure tokens. Do not change this.
export AUDIENCE="api://AzureADTokenExchange"
Entra ID supports federated identity credentials on two kinds of identity. Pick one.
=== “App registration (service principal)”
Create (or reuse) an app registration. Its **Application (client) ID** becomes
`cwii.dev/az-client-id`.
```bash
az ad app create --display-name "cwii-reports" --query appId -o tsv
# -> save the appId, e.g. 11111111-1111-1111-1111-111111111111
export APP_ID="11111111-1111-1111-1111-111111111111"
# Ensure a service principal exists for the app (needed for RBAC assignment).
az ad sp create --id "$APP_ID"
```
=== “User-assigned managed identity (UAMI)”
Create (or reuse) a UAMI. Its **Client ID** becomes `cwii.dev/az-client-id`.
```bash
export RESOURCE_GROUP="rg-cwii"
export LOCATION="eastus"
az identity create \
--name "cwii-reports" \
--resource-group "$RESOURCE_GROUP" \
--location "$LOCATION"
export APP_ID="$(az identity show \
--name cwii-reports --resource-group "$RESOURCE_GROUP" \
--query clientId -o tsv)"
```
In both cases also record your tenant ID, which becomes cwii.dev/az-tenant-id:
export TENANT_ID="$(az account show --query tenantId -o tsv)"
This is the trust anchor. It tells Entra ID to accept tokens whose iss, sub and aud claims
match exactly.
=== “App registration”
```bash
az ad app federated-credential create \
--id "$APP_ID" \
--parameters "$(cat <<JSON
{
"name": "cwii-${K8S_NAMESPACE}-${K8S_SA}",
"issuer": "${ISSUER_URL}",
"subject": "${SUBJECT}",
"audiences": ["${AUDIENCE}"],
"description": "cwii workload identity for ${SUBJECT}"
}
JSON
)"
```
=== “User-assigned managed identity”
```bash
az identity federated-credential create \
--name "cwii-${K8S_NAMESPACE}-${K8S_SA}" \
--identity-name "cwii-reports" \
--resource-group "$RESOURCE_GROUP" \
--issuer "$ISSUER_URL" \
--subject "$SUBJECT" \
--audiences "$AUDIENCE"
```
!!! danger “All three claims must match exactly”
- issuer must equal the kube-apiserver --service-account-issuer byte-for-byte (mind the
trailing slash and https:// scheme).
- subject must be exactly system:serviceaccount:NS:SA — the namespace and
ServiceAccount of the pod, not a display name.
- audiences must contain api://AzureADTokenExchange, which is exactly the audience cwii
requests for the Azure projected token.
If any claim differs, the SDK token exchange fails with `AADSTS70021: No matching federated
identity record found`.
Federation establishes who the workload is; RBAC establishes what it can do. Grant the identity the roles it needs on the target scope.
# Example: read access to a storage account.
export ASSIGNEE_OBJECT_ID="$(az ad sp show --id "$APP_ID" --query id -o tsv)" # app reg
# For a UAMI:
# export ASSIGNEE_OBJECT_ID="$(az identity show --name cwii-reports \
# --resource-group "$RESOURCE_GROUP" --query principalId -o tsv)"
az role assignment create \
--assignee-object-id "$ASSIGNEE_OBJECT_ID" \
--assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Reader" \
--scope "/subscriptions/<SUB_ID>/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/<ACCOUNT>"
!!! tip “Role propagation is eventually consistent”
New role assignments can take a minute or two to take effect. A workload may federate
successfully (it has a valid token) yet still get AuthorizationFailed on the data plane until
the assignment propagates.
cwii activates Azure injection per workload using annotations under the cwii.dev/ prefix. The two
required keys are the client ID and tenant ID from Steps 1–2.
apiVersion: v1
kind: Pod
metadata:
name: reports
namespace: apps
annotations:
cwii.dev/az-inject: "true"
cwii.dev/az-client-id: "11111111-1111-1111-1111-111111111111" # app/identity client ID
cwii.dev/az-tenant-id: "22222222-2222-2222-2222-222222222222" # Entra tenant ID
spec:
serviceAccountName: reports
containers:
- name: app
image: mcr.microsoft.com/azure-cli:latest
command: ["sleep", "infinity"]
The Azure-specific annotations are:
| Annotation | Required | Description |
|---|---|---|
cwii.dev/az-inject |
yes | Set to "true" to enable Azure injection ("false" suppresses it). |
cwii.dev/az-client-id |
yes | App registration / managed identity client ID. Injection requires it. |
cwii.dev/az-tenant-id |
yes | Entra ID tenant ID. Injection requires it. |
cwii.dev/az-authority-host |
no | Override the Entra authority host (e.g. for sovereign clouds). |
cwii.dev/az-audience |
no | Override the projected-token audience (default api://AzureADTokenExchange). |
cwii.dev/az-token-expiration |
no | Projected token lifetime in seconds (default 3600, Kubernetes minimum 600). |
cwii.dev/az-verify |
no | "true" adds a non-blocking can-i init container (see Step 6). |
cwii.dev/az-verify-enforce |
no | "true" makes a failed verify block pod startup. |
cwii.dev/az-verify-image |
no | Override the verify init-container image. |
!!! note “Annotation precedence”
cwii resolves each annotation key independently using the precedence
pod > owning workload > ServiceAccount > namespace. The first explicit value wins, so
a specific "false" on the pod can suppress a broader "true" set on the namespace, and one
provider never affects another. The owner walk resolves ReplicaSet -> Deployment (Deployment
annotations preferred) as well as StatefulSet, DaemonSet and Job. See the
Annotations reference for the full model.
In practice you usually annotate the **ServiceAccount** so every pod that runs as it inherits
the federation config:
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: reports
namespace: apps
annotations:
cwii.dev/az-inject: "true"
cwii.dev/az-client-id: "11111111-1111-1111-1111-111111111111"
cwii.dev/az-tenant-id: "22222222-2222-2222-2222-222222222222"
```
When the webhook admits the pod, it mutates the spec and writes the status marker
cwii.dev/injected with the comma-joined sorted provider abbreviations it acted on (for example
az, or aws,az,gcp for a multi-cloud pod).
Azure uses an env-vars-only mechanism — there is no credentials file. cwii adds these to every container in the pod:
| Variable | Value |
|---|---|
AZURE_CLIENT_ID |
from cwii.dev/az-client-id |
AZURE_TENANT_ID |
from cwii.dev/az-tenant-id |
AZURE_FEDERATED_TOKEN_FILE |
/var/run/secrets/cwii.dev/az/token |
AZURE_AUTHORITY_HOST |
from cwii.dev/az-authority-host (only if set) |
These are precisely the variables the Azure Identity SDKs (WorkloadIdentityCredential /
DefaultAzureCredential) read to perform the federated token exchange via the Entra ID mechanism.
cwii gives each enabled provider its own projected ServiceAccount token volume, because every cloud requires a different token audience. For Azure:
# Injected by cwii into the pod spec.
volumes:
- name: cwii-az-token
projected:
sources:
- serviceAccountToken:
path: token
audience: api://AzureADTokenExchange
expirationSeconds: 3600 # cwii.dev/az-token-expiration; min 600
# Injected into every container.
volumeMounts:
- name: cwii-az-token
mountPath: /var/run/secrets/cwii.dev/az
readOnly: true
The token file therefore lands at /var/run/secrets/cwii.dev/az/token, exactly where
AZURE_FEDERATED_TOKEN_FILE points.
!!! info “Why a dedicated volume per provider”
A projected ServiceAccount token is minted for a single audience. Azure requires
api://AzureADTokenExchange, AWS requires sts.amazonaws.com, and GCP requires its STS
audience. cwii mounts a separate cwii-<p>-token volume per provider so a multi-cloud pod
holds the correct, distinct token for each.
Set cwii.dev/az-verify: "true" to have cwii add a can-i init container named cwii-az-verify.
It runs (using the image mcr.microsoft.com/azure-cli:latest by default):
az login --service-principal ... --federated-token ... && az account show
metadata:
annotations:
cwii.dev/az-inject: "true"
cwii.dev/az-client-id: "11111111-1111-1111-1111-111111111111"
cwii.dev/az-tenant-id: "22222222-2222-2222-2222-222222222222"
cwii.dev/az-verify: "true"
| Mode | Annotation | Behaviour |
|---|---|---|
| Non-blocking (default) | cwii.dev/az-verify: "true" |
The check is wrapped as <check> \|\| echo ... >&2, so it always exits 0. Failures are logged only and do not block startup. |
| Enforcing | cwii.dev/az-verify-enforce: "true" |
The check runs bare. A non-zero exit blocks pod startup — useful for fail-fast rollouts. |
Override the image per workload with cwii.dev/az-verify-image, or cluster-wide via the Helm value
providers.az.verifyImage (server flag --az-verify-image). See Verification
for details and the ordering of init containers (verify runs at order 10).
Quick manual check once the pod is running:
kubectl -n apps exec deploy/reports -- env | grep AZURE_
kubectl -n apps exec deploy/reports -- \
cat /var/run/secrets/cwii.dev/az/token | cut -d. -f2 | base64 -d 2>/dev/null
# Inspect the decoded JWT body: aud should be api://AzureADTokenExchange,
# iss your cluster issuer, sub system:serviceaccount:apps:reports.
!!! danger “Common failure modes”
- AADSTS70021 / no matching federated identity record — the issuer, subject or
audiences in your federated credential do not match the token. Re-check all three (Step 2).
- Subject format — the subject must be exactly system:serviceaccount:NS:SA. A typo in
the namespace or ServiceAccount name silently breaks the match.
- Issuer mismatch — the federated credential issuer must equal the kube-apiserver
--service-account-issuer exactly, including scheme and any trailing slash.
- Audience — must be api://AzureADTokenExchange. If you override
cwii.dev/az-audience, you must register the same value in the federated credential’s
audiences.
- Federated credential limits — Entra ID caps the number of federated identity credentials
per app registration / managed identity. Reuse one identity across many ServiceAccounts only
up to that limit; beyond it, split workloads across multiple identities.
- Missing required annotations — Azure injection requires both cwii.dev/az-client-id
and cwii.dev/az-tenant-id. Without them cwii will not inject Azure for the pod.
- RBAC propagation delay — see Step 3; a freshly assigned role can lag by a minute or two.
can-i init containers and enforcement.cwii-system.