IRSA was the right answer for six years. You stood up an OIDC provider per cluster, annotated a service account with a role ARN, and the AWS SDK exchanged a projected token for credentials. It works. But every cluster you create is a new IAM identity provider, every role’s trust policy hard-codes a specific cluster’s OIDC issuer URL, and reusing one role across three clusters means a StringEquals condition that grows a line per cluster. EKS Pod Identity collapses that: one service principal, one trust policy, associations managed entirely in the EKS API. This is the migration I run for platform teams who have outgrown the OIDC sprawl, written to be incremental and fully reversible at every step.
Why migrate: what IRSA’s OIDC model costs you
IRSA’s trust anchor is an IAM OIDC identity provider that points at your cluster’s issuer URL. The role trust policy looks like this:
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:sub": "system:serviceaccount:payments:checkout",
"oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:aud": "sts.amazonaws.com"
}
}
}
Three structural problems show up at scale:
- Per-cluster identity providers. Each cluster has a unique OIDC issuer. A role meant to be shared across clusters needs every issuer registered as a provider and every
sub/audcondition repeated. Recreate a cluster and the issuer changes, breaking every role that trusted it. - Coupled ownership. The IAM team owns OIDC providers; the platform team owns clusters. Standing up a new cluster requires an IAM change ticket before any workload can assume a role.
- Condition-key sprawl. Multi-cluster, multi-namespace reuse turns the trust policy into a maintenance liability that few people fully understand.
Pod Identity replaces the federation anchor with a single AWS service principal, pods.eks.amazonaws.com, and moves the cluster/namespace/service-account binding out of IAM and into an EKS association resource.
| Concern | IRSA | EKS Pod Identity |
|---|---|---|
| Trust anchor | OIDC provider per cluster | One service principal, all clusters |
| Where the SA binding lives | IAM trust policy condition | EKS association (API resource) |
| Reuse a role across clusters | New provider + condition each | Same role, new association |
| Credential exchange | SDK calls STS in each pod | EKS Auth assumes once per node |
| Cross-namespace scoping | Hand-rolled conditions | Built-in session tags |
How Pod Identity works: the agent and the credential path
There are three moving parts.
- The Pod Identity Agent runs as a DaemonSet (
eks-pod-identity-agent), one pod per node, on the node’shostNetwork. It listens on a link-local address,169.254.170.23(and[fd00:ec2::23]for IPv6), on ports80and2703. Install it as a managed add-on. EKS Auto Mode clusters already have it. - The association is an EKS resource mapping
(cluster, namespace, service account) -> IAM role. You create it with the EKS API; nothing in Kubernetes changes except that the pod must use that service account. - Credential delivery. When a pod using an associated service account starts, EKS injects
AWS_CONTAINER_CREDENTIALS_FULL_URIandAWS_CONTAINER_AUTHORIZATION_TOKEN_FILEinto every container. The SDK’s default credential provider chain reads them and fetches credentials from the agent over the link-local endpoint. The agent calls the EKS Auth API (AssumeRoleForPodIdentity), which validates the association and returns temporary credentials. The assume happens once per node per role, not once per pod — that is the scalability win over IRSA.
Install the agent and confirm it is healthy:
aws eks create-addon \
--cluster-name platform-prod \
--addon-name eks-pod-identity-agent
kubectl get daemonset eks-pod-identity-agent -n kube-system
kubectl get pods -n kube-system -l app.kubernetes.io/name=eks-pod-identity-agent
If your cluster runs an HTTP proxy, add
169.254.170.23and[fd00:ec2::23]toNO_PROXYin your workloads, or the SDK’s credential request will be routed to the proxy and fail. This is the single most common Pod Identity bring-up failure.
Trust and session tags: one policy, many namespaces
The role’s trust policy no longer references any OIDC issuer. It trusts the EKS service principal and grants two actions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEksPodIdentity",
"Effect": "Allow",
"Principal": {
"Service": "pods.eks.amazonaws.com"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
]
}
]
}
sts:TagSession is required, not optional. EKS Pod Identity attaches a set of session tags on every assume, and without sts:TagSession the assume is denied. The six tags EKS injects are:
| Session tag key | Value |
|---|---|
eks-cluster-arn |
Full ARN of the cluster |
eks-cluster-name |
Cluster name |
kubernetes-namespace |
Pod’s namespace |
kubernetes-service-account |
Service account name |
kubernetes-pod-name |
Pod name |
kubernetes-pod-uid |
Pod UID |
These tags are the lever that lets one role serve many workloads safely. You can write a single role whose permission policy is scoped per namespace using aws:PrincipalTag:
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::tenant-data/*",
"Condition": {
"StringEquals": {
"aws:PrincipalTag/kubernetes-namespace": "payments"
}
}
}
The same role assumed from a pod in analytics gets a different kubernetes-namespace tag and is denied. With IRSA you would have needed two roles and two trust conditions; here it is one role and a tag comparison. Note the trust policy is identical across all clusters — you never edit it per cluster, which is the operational point.
Step 1 — Map your IRSA service accounts to associations
Before changing anything, enumerate what you have. Every IRSA service account carries the eks.amazonaws.com/role-arn annotation:
kubectl get sa --all-namespaces -o json \
| jq -r '.items[]
| select(.metadata.annotations["eks.amazonaws.com/role-arn"] != null)
| [.metadata.namespace, .metadata.name,
.metadata.annotations["eks.amazonaws.com/role-arn"]]
| @tsv'
That gives you the exact (namespace, service account, role ARN) tuples to migrate. For each one, you create an association — the role can stay the same; only its trust policy changes.
For a single service account:
aws eks create-pod-identity-association \
--cluster-name platform-prod \
--namespace payments \
--service-account checkout \
--role-arn arn:aws:iam::111122223333:role/payments-checkout
In practice you want this in IaC. Terraform:
resource "aws_eks_pod_identity_association" "checkout" {
cluster_name = "platform-prod"
namespace = "payments"
service_account = "checkout"
role_arn = aws_iam_role.payments_checkout.arn
}
data "aws_iam_policy_document" "pod_identity_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole", "sts:TagSession"]
principals {
type = "Service"
identifiers = ["pods.eks.amazonaws.com"]
}
}
}
Update each migrated role’s assume_role_policy to data.aws_iam_policy_document.pod_identity_trust.json. If you keep the OIDC AssumeRoleWithWebIdentity statement and add the pods.eks.amazonaws.com statement, the role works under both mechanisms simultaneously — which is exactly what you want during cutover.
Do not delete the IRSA annotation in the same change that creates the association. Pod Identity and IRSA can coexist on a role; keeping both live gives you a clean rollback.
Step 2 — Incremental rollout: per-namespace cutover
The credential source a pod actually uses is decided at pod start. IRSA injects AWS_WEB_IDENTITY_TOKEN_FILE; Pod Identity injects AWS_CONTAINER_CREDENTIALS_FULL_URI. If both are present, the SDK’s default credential provider chain prefers the container credentials (Pod Identity) over web identity. So the cutover sequence per namespace is:
- Create the association for every service account in the namespace.
- Add the
pods.eks.amazonaws.comstatement to each role’s trust policy (keep the OIDC statement). - Roll the workloads so new pods pick up the injected variables:
kubectl rollout restart deployment -n payments
- Confirm the pods now carry Pod Identity variables and that AWS calls still succeed (see Verify). Watch CloudTrail for
AssumeRoleForPodIdentityevents from the namespace. - Only after a soak period, remove the
eks.amazonaws.com/role-arnannotation and the OIDC trust statement.
Pick a low-risk namespace first — internal tooling, not payments. Because the association is an EKS resource and not a pod mutation, creating it has zero effect until pods restart, so you control the blast radius entirely through rollout restart.
Associations are eventually consistent — allow several seconds after
create-pod-identity-associationbefore restarting workloads, and never create associations inside a hot, high-availability code path. Do it in setup/init flows.
Step 3 — Cross-account and multi-cluster access patterns
Two patterns cover almost everything.
Multi-cluster, same role. This is where Pod Identity shines. Create the identical association in each cluster pointing at the same role; the trust policy needs no edits because no issuer is referenced. Same Terraform module, different cluster_name:
resource "aws_eks_pod_identity_association" "checkout" {
for_each = toset(["platform-prod-use1", "platform-prod-euw1"])
cluster_name = each.value
namespace = "payments"
service_account = "checkout"
role_arn = aws_iam_role.payments_checkout.arn
}
Cross-account. The cluster is in account A; the workload needs a role in account B. Pod Identity supports this natively with --target-role-arn: the association’s role-arn (in account A) is assumed first, then it assumes the target role in account B, and the target’s credentials are injected into the pod.
aws eks create-pod-identity-association \
--cluster-name platform-prod \
--namespace data-pipeline \
--service-account ingest \
--role-arn arn:aws:iam::111122223333:role/pod-id-ingest \
--target-role-arn arn:aws:iam::444455556666:role/cross-acct-ingest
The account-A role trusts pods.eks.amazonaws.com as above and must be allowed to sts:AssumeRole on the account-B role. The account-B target role’s trust policy then trusts the account-A role ARN. This replaces the IRSA “role chaining via SDK config” hack with a first-class flag.
You can also attach a session policy that further restricts the injected credentials with --policy. When you use --policy you must pass --disable-session-tags, because a session policy and EKS session tags cannot be combined on the same assume:
aws eks create-pod-identity-association \
--cluster-name platform-prod \
--namespace data-pipeline \
--service-account ingest \
--role-arn arn:aws:iam::111122223333:role/pod-id-ingest \
--disable-session-tags \
--policy '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::ingest-bucket/*"}]}'
Be deliberate here: disabling session tags removes the kubernetes-namespace lever, so any namespace-scoped conditions on the role stop matching. Use --policy only when you intend to scope through the inline policy instead.
Verify
Confirm the migration end to end, from association down to an actual signed AWS call.
List associations and confirm the binding:
aws eks list-pod-identity-associations --cluster-name platform-prod
aws eks describe-pod-identity-association \
--cluster-name platform-prod --association-id a-abc123def456
Confirm the pod received Pod Identity variables (not IRSA’s):
kubectl exec -n payments deploy/checkout -- env | grep AWS_CONTAINER
# AWS_CONTAINER_CREDENTIALS_FULL_URI=http://169.254.170.23/v1/credentials
# AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=/var/run/secrets/pods.eks.amazonaws.com/serviceaccount/eks-pod-identity-token
Verify effective permissions from inside the pod — this is the only check that proves the whole path works:
kubectl exec -n payments deploy/checkout -- aws sts get-caller-identity
The returned Arn should be an assumed-role session of the associated role (an arn:aws:sts::...:assumed-role/... value), not the node instance role. If you see the node role, the agent is not serving credentials — check the proxy/NO_PROXY settings and that the pod’s service account name exactly matches the association.
Cross-check the source of truth in CloudTrail. Pod Identity assumes surface as AssumeRoleForPodIdentity calls by the EKS Auth service; the session tags appear in the event, letting you confirm the namespace and service account that triggered each assume.
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleForPodIdentity \
--max-results 10
Enterprise scenario
A fintech platform team ran 11 EKS clusters across two regions for blue/green and tenant isolation. A shared “telemetry shipper” DaemonSet on every cluster needed firehose:PutRecordBatch to a central account. Under IRSA, that meant 11 OIDC providers registered as trusted in the central account’s role, and an 11-clause StringEquals block in the trust policy keyed on each cluster’s issuer URL. Every cluster rebuild changed an issuer and silently broke shipping until someone updated the trust policy — they had been paged for it twice.
The constraint: they could not coordinate an IAM change every time the platform team recycled a cluster, and security would not approve a wildcard trust. The fix was Pod Identity with a single cross-account target role and one association per cluster, all generated from the same module. The central role’s trust policy stopped referencing any cluster at all:
resource "aws_eks_pod_identity_association" "telemetry" {
for_each = toset(var.cluster_names) # all 11
cluster_name = each.value
namespace = "observability"
service_account = "telemetry-shipper"
role_arn = aws_iam_role.pod_id_telemetry.arn # local per-account
target_role_arn = "arn:aws:iam::999988887777:role/firehose-writer"
}
The firehose-writer role in the central account trusts only the per-account pod_id_telemetry role ARN — a single, static principal — and scopes writes to the namespace using aws:PrincipalTag/kubernetes-namespace. Cluster rebuilds became a non-event: the new cluster’s association is created by the same for_each, the trust policy never changes, and security reviews one static cross-account trust instead of an issuer list. The 11-clause condition block went to zero.
Migration checklist
The reversibility is the whole point: until you remove the annotation and the OIDC trust statement, a single kubectl rollout restart after deleting the association drops you straight back to IRSA. Migrate one namespace, prove it in CloudTrail, and move on.