Kubernetes pods as cloud identities: EKS IRSA vs. AKS Workload Identity
How a Kubernetes ServiceAccount becomes a real cloud identity - the OIDC federation trust chain behind AWS IRSA and Azure AKS Workload Identity, mapped one-to-one, with the wrinkles that break your muscle memory.
Both AWS and Azure solve the same problem the same way: a pod authenticates to cloud APIs as itself, with a short-lived token and no stored secret, by turning its Kubernetes ServiceAccount into a federated cloud identity. On AWS the feature is IRSA (IAM Roles for Service Accounts); on Azure it's Microsoft Entra Workload Identity (the successor to the deprecated aad-pod-identity). Under the hood both are the same OIDC federation trick, and the moving parts line up almost one-to-one.
The short version, if you only read one paragraph: the cluster becomes an OpenID Connect issuer that signs a token for
each pod's ServiceAccount; the cloud's identity provider trusts that issuer and exchanges the token for real cloud
credentials scoped to one identity. AWS calls the identity an IAM role and gates it with a trust policy; Azure calls it
a user-assigned managed identity and gates it with a federated identity credential. The subject string
system:serviceaccount:<namespace>:<name> is the linchpin on both. Three things differ enough to bite you: Azure splits
the "OIDC provider" into two separate cluster toggles that both default off, Azure additionally requires a label on the
pod itself, and pulling your container image is a different identity from your workload identity on both clouds.
The problem both solve
Before federation, giving a pod cloud permissions meant bad options: bake a static access key into the image or a Secret (leaks, rotation pain), or grant the permission to the whole node so every pod on it inherits it (no isolation, wildly over-scoped). The AWS-side stopgaps were node-IMDS interceptors like kube2iam and kiam; the Azure-side one was aad-pod-identity. All of them proxied the node's identity and were fiddly and race-prone.
Federation replaces that with cryptographic trust. The kubelet projects a signed, short-lived OIDC token into the pod, scoped to the pod's ServiceAccount. The cloud IdP verifies that token's signature against the cluster's published public keys and, if the token's issuer, subject, and audience match a trust rule you configured, hands back credentials for exactly one cloud identity. No secret is stored anywhere; the projected token auto-rotates (default lifetime about an hour) and is useless outside the cluster.
The shared mechanism: OIDC federation
Every workload-identity setup, on either cloud, is the same trust triangle:
- The cluster is an OIDC issuer. It exposes a discovery document at
<issuer-url>/.well-known/openid-configurationand a JWKS endpoint with the public keys it signs ServiceAccount tokens with. That issuer URL is the trust anchor. - The cloud IdP trusts that issuer for a specific
(subject, audience)pair. The subject identifies which ServiceAccount, and the audience identifies who the token is for. - The workload exchanges the projected token for cloud credentials. The SDK reads the token file the kubelet projected, presents it to the cloud's token endpoint, and receives short-lived credentials for a single cloud identity.
Everything else is naming. Here's the map, walked row by row.
The one-to-one map
- The Kubernetes side. On AWS you annotate the ServiceAccount with
eks.amazonaws.com/role-arn: <role arn>. On Azure you annotate it withazure.workload.identity/client-id: <managed identity client id>and label the pod withazure.workload.identity/use: "true". Both point the pod at a cloud identity; Azure needs the extra pod label (more on that below). - The "OIDC provider." On AWS this is the EKS cluster's OIDC issuer registered once in IAM as an OIDC identity
provider. On Azure it is two cluster settings: the OIDC issuer (
oidcIssuerProfile.enabled) that publishes the issuer URL and signing keys, and the workload-identity add-on (securityProfile.workloadIdentity.enabled), a mutating admission webhook. Both must be on. - The identity. AWS: an IAM role. Azure: a user-assigned managed identity (UAMI). This is the object the pod becomes.
- The trust rule. AWS: the IAM role's trust policy federates the OIDC provider and pins
sub = system:serviceaccount:<ns>:<sa>andaud = sts.amazonaws.com. Azure: a federated identity credential on the UAMI withissuer = <cluster OIDC issuer url>,subject = system:serviceaccount:<ns>:<sa>, andaudience = api://AzureADTokenExchange. Same three fields, different homes. - The permissions. AWS: an IAM policy attached to the role (read this S3 bucket, pull from this ECR repo). Azure: RBAC role assignments on the target resources (Key Vault Secrets User, Storage Blob Data Reader, AcrPull, ...). This is the deeper philosophical split - AWS staples a policy to the identity; Azure grants a role at the resource's scope.
- The exchange. AWS: the SDK calls
sts:AssumeRoleWithWebIdentitywith the projected token. Azure: the SDK does an Entra token exchange viaDefaultAzureCredential/WorkloadIdentityCredential, presenting the projected token as a client assertion. Both yield short-lived credentials, both need zero secret material in your code.
The rest of this post walks each cloud end to end, then dwells on the differences that actually trip people up.
Walk-through: AWS EKS IRSA, end to end
The pieces, in the order the trust flows:
1. The cluster OIDC issuer. Every EKS cluster has one, at a URL like
https://oidc.eks.<region>.amazonaws.com/id/<hash>. You register it once in IAM as an OIDC identity provider so IAM
will trust tokens it signs.
2. The IAM role and its trust policy. The trust policy is what makes the role assumable by a specific ServiceAccount and nothing else:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<account>:oidc-provider/oidc.eks.<region>.amazonaws.com/id/<hash>" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.<region>.amazonaws.com/id/<hash>:sub": "system:serviceaccount:apps:checkout",
"oidc.eks.<region>.amazonaws.com/id/<hash>:aud": "sts.amazonaws.com"
}
}
}
Pin both :sub and :aud. Pinning only the issuer would let any ServiceAccount in the cluster assume the role - a
classic over-trust bug.
3. The IAM policy. A normal identity-based policy attached to the role grants what the app actually needs
(s3:GetObject on one bucket, ecr:GetDownloadUrlForLayer, and so on).
4. The ServiceAccount. A plain ServiceAccount with one annotation:
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout
namespace: apps
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::<account>:role/checkout
5. The projection. EKS ships the Pod Identity Webhook built into the cluster. When a pod uses an annotated
ServiceAccount, the webhook mutates it: it injects AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE env vars and mounts
a projected token volume (audience sts.amazonaws.com, auto-rotated). You don't label the pod; the SA annotation is
enough.
6. The exchange. The AWS SDK's default credential chain notices AWS_WEB_IDENTITY_TOKEN_FILE, calls
sts:AssumeRoleWithWebIdentity with the token, and caches the returned short-lived credentials. Your code is just
boto3.client("s3") - no credential handling at all.
Newer alternative: EKS Pod Identity (2023) does the same job through an on-cluster agent and an association API instead of a per-cluster IAM OIDC provider and per-role trust policy. It's easier to operate at scale but is AWS-specific plumbing; the OIDC-federation model above is the one that maps cleanly to Azure, so it's the one to hold in your head for a cross-cloud comparison.
Walk-through: Azure AKS Workload Identity, end to end
Same trust flow, Azure nouns:
1. Two cluster toggles. Turn on the OIDC issuer (oidcIssuerProfile.enabled = true), which publishes an issuer
URL like https://<region>.oic.prod-aks.azure.com/<tenant>/<guid>/, and the workload-identity add-on
(securityProfile.workloadIdentity.enabled = true), which installs the mutating webhook. Both default to off on a new
cluster, and the cluster's oidcIssuerUrl is null until the first is enabled. Enabling them is an in-place update, not
a recreate.
2. The managed identity. Create a user-assigned managed identity. (Federated credentials attach only to user-assigned identities, not system-assigned ones.) Its client id is what the ServiceAccount will reference.
3. The federated identity credential. This is the trust rule, a child object on the UAMI:
issuer: https://<region>.oic.prod-aks.azure.com/<tenant>/<guid>/
subject: system:serviceaccount:apps:checkout
audience: api://AzureADTokenExchange
Note the same subject grammar as AWS and a different fixed audience.
4. The RBAC. Grant the UAMI's principal the roles it needs on the target resources: Key Vault Secrets User on a
vault, Storage Blob Data Reader on a storage account, AcrPull on a registry if the app calls the registry data plane
itself. There is no policy document on the identity; access is role assignments at each resource's scope.
5. The ServiceAccount and the pod label. The ServiceAccount carries the client-id annotation, and - this is the part AWS folks forget - the pod must be labeled too:
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout
namespace: apps
annotations:
azure.workload.identity/client-id: <uami-client-id>
---
# in the Deployment's pod template:
metadata:
labels:
azure.workload.identity/use: "true" # required on the POD, not just the SA
spec:
serviceAccountName: checkout
6. The projection. The add-on's webhook only mutates pods that carry the azure.workload.identity/use: "true"
label. When it fires, it injects AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE, and
AZURE_AUTHORITY_HOST, and projects a token with audience api://AzureADTokenExchange.
7. The exchange. DefaultAzureCredential() (or the explicit WorkloadIdentityCredential) reads those env vars,
presents the projected token to Entra ID as a client assertion, and gets back an access token for the target resource.
A Key Vault read is azure.identity.DefaultAzureCredential() plus SecretClient in Python, or
new DefaultAzureCredential() plus @azure/keyvault-secrets in TypeScript - and again, no secret anywhere in the code.
The subject string is the linchpin (on both clouds)
The single value that must match exactly, on both clouds, is the token subject:
system:serviceaccount:<namespace>:<serviceaccount-name>
The kubelet stamps this into the token based on the pod's actual namespace and ServiceAccount. The trust rule (IAM trust
policy on AWS, federated credential on Azure) pins the string it will accept. If the app runs in namespace apps under
ServiceAccount checkout, the trust rule must say system:serviceaccount:apps:checkout - character for character. The
most common "it authenticates as nobody" failure is a mismatch here: the chart deployed into a different namespace, the
ServiceAccount got the release name instead of the app name, or someone assumed the default ServiceAccount. When
federation silently fails, check the subject first.
The audience is the other pinned field, and it's a fixed constant per cloud: sts.amazonaws.com on AWS,
api://AzureADTokenExchange on Azure. You rarely change it, but the trust rule and the projected token must agree on
it.
Four differences that will actually trip you up
1. Azure splits the "OIDC provider" into two toggles, and both default off. On EKS the pod-identity webhook is built
into the cluster and the only one-time step is registering the OIDC provider in IAM. On AKS you must separately enable
the OIDC issuer and the workload-identity add-on; forget the add-on and the issuer still publishes tokens but nothing
projects them into pods, so the app sees no AZURE_* env vars and DefaultAzureCredential quietly falls through to the
next credential source. Enable both, and remember the federated credential can't be created until the issuer URL exists.
2. Azure needs a label on the pod, not just an annotation on the ServiceAccount. The AKS webhook keys off the
azure.workload.identity/use: "true" label on the pod (the ServiceAccount annotation alone is not enough). IRSA's
webhook keys off the ServiceAccount annotation and needs no pod label. This is the single most common Azure-side
gotcha: the ServiceAccount looks perfect, but the pod template forgot the label, so no token is projected.
3. Pulling the image is a different identity from the workload identity - on both clouds. Image pull is the
node/kubelet identity's job: on AKS the kubelet identity holds AcrPull; on EKS the node group's instance role
holds AmazonEC2ContainerRegistryReadOnly (or you use pull secrets). Workload identity is only needed when the app
code calls a cloud API (read a secret, list a bucket, call the registry's data plane). A pure HTTP service that never
talks to a cloud SDK needs no workload identity at all - and conversely, granting a workload identity AcrPull does
nothing for image pull, because the pull happens before the app (and its federated token) ever runs.
4. Permissions attach differently. AWS staples an IAM policy to the role - the permission travels with the identity. Azure grants RBAC role assignments at the target resource's scope - the grant lives on the thing being accessed, not on the identity. Same end state (this pod can read that vault), but you look in different places to audit it: on AWS, read the role's attached policies; on Azure, list role assignments on the resource (or the identity's assignments across scopes). This mirrors the broader IAM-vs-RBAC difference between the two clouds.
What the injected environment looks like
Both webhooks hand the SDK everything it needs through env vars and a projected file, so the app code references no secret and no cloud-specific credential logic:
- AWS:
AWS_ROLE_ARN,AWS_WEB_IDENTITY_TOKEN_FILE,AWS_REGION. The default credential chain callsAssumeRoleWithWebIdentityfor you. - Azure:
AZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_FEDERATED_TOKEN_FILE,AZURE_AUTHORITY_HOST.DefaultAzureCredentialdoes the Entra exchange for you.
In both cases the projected token file is short-lived and auto-rotated by the kubelet; the SDK re-reads it and refreshes the cloud credentials transparently. There is nothing to rotate, nothing to store, and nothing to leak.
A debugging checklist that works on both clouds
When a pod "can't authenticate," walk the trust chain in order:
- Cluster issuer on? Confirm the OIDC issuer URL exists (AKS: both toggles true; EKS: the IAM OIDC provider is registered). No issuer, no trust anchor.
- Projection happening? Exec into the pod and check for the token file and env vars
(
AWS_WEB_IDENTITY_TOKEN_FILE/AZURE_FEDERATED_TOKEN_FILE). Missing on Azure usually means the pod label is absent or the add-on is off. - Subject match? Compare the trust rule's subject to
system:serviceaccount:<actual-namespace>:<actual-sa>. This is the most frequent failure. - Audience match?
sts.amazonaws.com/api://AzureADTokenExchange, agreeing on both sides. - Permissions? Only after the identity resolves: the IAM policy on the role, or the RBAC role assignment on the target resource. An empty result here is an authorization failure, not an authentication one - a useful distinction, because it tells you federation itself is working.
Which certifications drill this
Workload identity sits right where Kubernetes, cloud IAM, and OIDC federation meet, so it shows up across three exam tracks. If you already hold one cloud's credential, the same concept on the other cloud is a short hop.
On the AWS side:
- AWS Certified Solutions Architect - Associate (SAA-C03) - IAM roles, EKS, and how identities get cloud permissions.
- AWS Certified Security - Specialty (SCS-C03) - IAM trust policies, OIDC federation, and least-privilege scoping (exactly the IRSA trust chain).
On the Azure side:
- Microsoft Azure Administrator Associate (AZ-104) - AKS, managed identities, and RBAC role assignments.
- Microsoft Azure Security Engineer Associate (AZ-500) - Entra ID, managed identities, federated credentials, and RBAC depth.
On the Kubernetes side:
- CNCF Certified Kubernetes Administrator (CKA) - ServiceAccounts, projected tokens, and pod specs.
- CNCF Certified Kubernetes Security Specialist (CKS) - ServiceAccount token security, least privilege, and reducing static-secret exposure.
The bottom line
IRSA and AKS Workload Identity are the same idea wearing different vocabulary: the cluster signs a short-lived OIDC
token for a ServiceAccount, the cloud IdP trusts that issuer for one (subject, audience), and the pod exchanges the
token for credentials scoped to a single cloud identity - an IAM role on AWS, a managed identity on Azure. Learn the
trust triangle once and the translation is mechanical: role becomes managed identity, trust policy becomes federated
credential, attached policy becomes RBAC assignment, AssumeRoleWithWebIdentity becomes an Entra token exchange. Keep
three things front of mind - Azure's two cluster toggles, Azure's required pod label, and the fact that image pull is a
different identity entirely - and the denials that used to look random start reading as a coherent, deliberate design.