AWS Containers

EKS Pod IAM Permissions: IRSA and EKS Pod Identity Hands-On

A pod in your EKS cluster needs to read one S3 bucket. The quickest way to make that work is also the most dangerous one: attach s3:GetObject to the node instance role — the IAM role on the EC2 worker node — and the pod’s SDK, falling back to the instance metadata service, picks the permission up for free. It works on the first try. It also just handed that same S3 access to every other pod scheduled on that node: the logging sidecar, the third-party agent you helm install-ed last week, the next tenant’s workload, and anything an attacker lands after a container escape. CloudTrail will record the node role making the call, so you cannot even tell which pod read the data. You have traded thirty seconds of setup for a blast radius the size of the whole node.

The correct answer is per-pod IAM identity: each workload assumes its own narrowly scoped role, gets short-lived credentials, and can be audited individually. AWS gives you two mechanisms to do this. IRSA (IAM Roles for Service Accounts) — the original, since 2019 — federates your cluster’s OIDC issuer into IAM so a Kubernetes ServiceAccount can exchange a projected token for role credentials via sts:AssumeRoleWithWebIdentity. EKS Pod Identity — GA since late 2023 — does the same job with a node agent and a simple association, no per-cluster OIDC provider and a trust policy that is identical across every cluster. Both replace the node role. Neither uses a long-lived access key.

This is the reference that takes you from “why is my pod using the node role?” to a working, least-privilege setup in both mechanisms. It treats pod identity as what it actually is — an OIDC token exchange (IRSA) or an agent-brokered association (Pod Identity) that ends in short-lived STS credentials — and walks every moving part: the OIDC provider and its thumbprint, the eks.amazonaws.com/role-arn annotation, the trust-policy sub/aud conditions that decide who may assume, the webhook that injects the env vars and mounts the token, the runtime credential chain, and the agent endpoint at 169.254.170.23. You build the exact same S3-reader workload twice — once with IRSA, once with Pod Identity — prove it is denied a second bucket, then tear it down. The bulk is a rigorous IRSA vs Pod Identity vs node role comparison and a 15-row troubleshooting playbook with the real STS error strings. It maps to the security and container domains of DVA-C02, SAA-C03, SOA-C02 and SCS-C02.

What problem this solves

Kubernetes has its own identity for a pod — the ServiceAccount — but AWS has never heard of it. When your code calls s3.GetObject, the AWS SDK needs AWS credentials, and by default on an EC2-backed node the only credentials in reach are the node’s instance profile: the IAM role attached to the worker node so the kubelet can join the cluster, pull images from ECR, and run the VPC CNI. That role is deliberately powerful (it can manipulate ENIs) and it is shared by every pod on the node. Using it for application permissions is the container-era version of putting the root account’s keys in a shared environment variable.

What breaks without per-pod identity is least privilege and auditability, quietly, until a security review or an incident. Grant the node role the union of every permission every pod needs, and each pod now has the superset — the payments pod can read the analytics bucket, the analytics pod can write to the payments queue, and a single compromised container inherits all of it. There is no per-workload scoping, no per-workload rotation, and no per-workload trail: CloudTrail’s userIdentity is the node role and the instance ID, so “who deleted the objects?” resolves to “some pod on i-0abc123, good luck.” Teams paper over this with long-lived IAM user access keys baked into Kubernetes Secrets — which is worse: the keys never expire, leak in kubectl describe, get committed to Git, and survive the pod that used them.

Who hits this: everyone who runs a real workload on EKS past “hello world.” It bites hardest on multi-tenant clusters (many teams, one node pool — the node role becomes a shared-secret nightmare), on regulated workloads (SCS-C02, PCI, SOC 2 all want per-workload identity and rotation), and on anyone doing a security review who discovers that “the pod can read S3” actually means “any pod on any node can read all S3 the node role allows.” The fix is to give the pod its own identity. IRSA and Pod Identity are the two supported, keyless ways to do exactly that — and the rest of this article is how each one works, wire by wire, and how to debug it when the pod stubbornly keeps using the node role anyway.

Here is the whole decision on one screen — the three ways a pod can get AWS credentials, and why only two of them are acceptable:

Mechanism How the pod gets creds Scope Rotation Audit (CloudTrail identity) Verdict
Node instance role SDK falls back to IMDS → instance profile Every pod on the node Node role creds rotate, but scope is shared Node role + instance ID (can’t tell which pod) ❌ Over-privileged; use only for kubelet/CNI
Long-lived IAM user key Access key in a Kubernetes Secret / env var Whatever the user has Never (until you rotate manually) The IAM user (shared, leaks easily) ❌ Never do this
IRSA Projected OIDC token → AssumeRoleWithWebIdentity Per ServiceAccount Short-lived STS creds, auto-refreshed Assumed-role session per SA ✅ Original, works everywhere incl. Fargate
EKS Pod Identity Agent brokers creds via association Per ServiceAccount Short-lived, auto-refreshed Assumed-role session + pod/ns/sa session tags ✅ Simpler trust, cross-cluster; EC2 nodes only

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with the EKS basics: a cluster has a managed control plane and worker nodes (EC2 in a managed node group, or Fargate), each node has an instance profile (IAM role), and you talk to the cluster with kubectl. You should know that a ServiceAccount is a namespaced Kubernetes identity that pods run as (the default SA if you don’t set serviceAccountName), and that a Pod references one. On the AWS side you need IAM roles, trust policies vs permissions policies, and the idea that STS issues short-lived credentials. You’ll want the aws, eksctl, and kubectl CLIs installed and a cluster you can afford to run (the control plane is not free — see Cost & sizing).

This sits in the Containers track and is the security spine under everything you deploy on EKS. Build the cluster itself first in Amazon EKS: Standing Up a Cluster with eksctl, Node Groups & kubectl Access; when pods won’t schedule or crash, the companion is EKS Pod Troubleshooting: Pending, CrashLoopBackOff, ImagePull & CNI IP Exhaustion. The IAM machinery underneath — how a role trust is evaluated and how one principal assumes another — is Cross-Account Access with IAM Roles & sts:AssumeRole: A Hands-On Guide, and when the token exchange returns AccessDenied you debug it with Debugging IAM ‘Access Denied’: Policy Evaluation Logic, SCPs, Boundaries & a Playbook. If you are still deciding whether EKS is even the right runtime, see Choosing Your AWS Container Path: ECS vs EKS vs Fargate.

Before the deep dive, fix where each piece of the problem lives, so you look in the right layer first:

Layer What lives here Failures it causes First place to look
Kubernetes ServiceAccount The pod’s identity + role-arn annotation Wrong SA, missing annotation, pod not restarted kubectl get sa -o yaml; pod’s serviceAccountName
Admission webhook Injects env vars + mounts the projected token Env unset, no token volume, pod falls to node role kubectl exec -- env | grep AWS_
Projected token The signed OIDC JWT (sub/aud/exp) Wrong audience, expired, clock skew Decode the token file inside the pod
IAM OIDC provider IAM’s trust of the cluster issuer (IRSA only) Provider not registered, wrong ARN aws iam list-open-id-connect-providers
IAM role trust policy Who may assume (sub/aud / pods.eks…) AccessDenied on the assume call aws iam get-role --query Role.AssumeRolePolicyDocument
IAM permissions policy What the role can do AccessDenied on the actual S3/DynamoDB call aws iam list-attached-role-policies; simulate-principal-policy
Pod Identity agent Brokers creds on the node (Pod Identity only) No creds — agent add-on missing/not on node kubectl get ds eks-pod-identity-agent -n kube-system

Core concepts

The AWS SDK credential provider chain — and where the node role sneaks in

Every AWS SDK resolves credentials by walking a provider chain in order and taking the first one that yields credentials. The exact order differs slightly between SDKs, but the invariants that matter for EKS are the same everywhere: explicit env keys win first, then web identity (IRSA), then container credentials (Pod Identity and ECS), and — dead last — EC2 instance metadata (the node role). The whole reason a pod “accidentally uses the node role” is that nothing earlier in the chain produced credentials, so the SDK walked all the way to IMDS.

Order (representative) Provider Trigger On EKS this is…
1 Static env keys AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY A long-lived key you should not be using
2 Web identity AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN IRSA (webhook injects these)
3 Shared config/credentials ~/.aws/credentials, profiles Rare in a container
4 Container credentials AWS_CONTAINER_CREDENTIALS_FULL_URI / RELATIVE_URI Pod Identity (agent) and ECS task roles
5 EC2 IMDS Reachable 169.254.169.254 instance profile The node role — the fallback you don’t want

Two consequences fall straight out of this table. First, if the IRSA env vars are present, the SDK uses IRSA and never reaches IMDS — so a correct IRSA setup is self-enforcing as long as the webhook injected the vars. Second, if neither web-identity nor container creds are present, the SDK silently uses the node role — no error, just the wrong identity. That silent fallback is the single most common source of confusion, and the first thing you confirm with aws sts get-caller-identity.

Why the node role is architecturally wrong

The node role isn’t “insecure” in isolation — it’s coarse. Its problem is granularity: the unit of permission is the node, but the unit of workload is the pod, and many pods share a node.

Property Node instance role Per-pod role (IRSA / Pod Identity)
Granted to The EC2 node (kubelet) One ServiceAccount
Shared by Every pod scheduled on that node Only pods using that SA
Blast radius of a compromise All permissions of the node role Just that workload’s scoped policy
CloudTrail identity Node role + instance ID Assumed-role session (traceable to SA)
Rotation Node role creds rotate; scope is static Short-lived per-session creds
Right use kubelet, ECR pull, VPC CNI, EBS CSI Application AWS permissions
Least privilege Impossible (union of all pods) One role per workload

The node role should carry only what the node itself needs — typically the managed policies AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly, and (unless you moved the CNI to IRSA) AmazonEKS_CNI_Policy. Application permissions do not belong here. If you find s3:* or dynamodb:* on a node role, that is the bug.

Per-pod identity is only half the defence — you also want to make the node-role fallback physically unreachable from a pod by hardening IMDS. These are the exact knobs:

Setting Values Recommended Effect Gotcha
HttpTokens (IMDSv2) optional / required required Forces session-token IMDSv2; blocks trivial SSRF reads Ancient SDKs may not speak IMDSv2
HttpPutResponseHopLimit 164 1 A pod is one hop away → can’t reach 169.254.169.254 Some host-network agents legitimately need 2
HttpEndpoint enabled / disabled enabled (with hop 1) Disabling entirely breaks kubelet/CNI Don’t disable on the node — restrict the hop instead
Pod-level egress to 169.254.169.254 NetworkPolicy deny Deny for app namespaces Blocks metadata even if hop limit is loose Needs a CNI that enforces NetworkPolicy
CNI AWS_VPC_K8S_CNI_EXTERNALSNAT etc. Leave default Unrelated; don’t confuse with IMDS

IRSA — the four parts and how they connect

IRSA is an OIDC federation: your cluster is an OpenID Connect identity provider, IAM trusts it, and a pod exchanges a signed token for role credentials. Four pieces must line up.

Part What it is Where it lives Created by
OIDC provider The cluster’s issuer URL registered as an IAM identity provider IAM (account-global) eksctl utils associate-iam-oidc-provider or aws iam create-open-id-connect-provider
IAM role + trust policy Trust federates the OIDC provider; conditions pin sub/aud IAM You / eksctl create iamserviceaccount
Permissions policy The scoped s3:GetObject … the workload actually needs IAM (attached to the role) You
ServiceAccount annotation eks.amazonaws.com/role-arn: arn:aws:iam::…:role/… Kubernetes (namespaced) kubectl annotate / manifest / eksctl

The issuer URL looks like https://oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E. Every cluster has one; enabling IRSA means registering it in IAM so STS will accept tokens it signs. The provider’s audience (client ID) is sts.amazonaws.com, and its thumbprint is the SHA-1 of the issuer’s TLS CA — eksctl and modern IAM compute it for you.

The projected ServiceAccount token

The token the pod presents is not a legacy, never-expiring Kubernetes Secret token. It is a projected service account token — a short-lived, audience-scoped JWT that kubelet mounts into the pod and rotates automatically. The webhook configures the projection with audience: sts.amazonaws.com so the token is only valid for the STS exchange.

JWT claim Value on an IRSA token Why it matters
iss https://oidc.eks.<region>.amazonaws.com/id/<hash> Must match the registered OIDC provider
sub system:serviceaccount:<namespace>:<serviceaccount> The trust policy pins this — who may assume
aud sts.amazonaws.com The trust policy pins this — must not be blank
exp ~1 hour (default 86400s max, rotated early) Expired token → InvalidIdentityToken
iat / nbf Issue / not-before time Clock skew here → token validation fails
kubernetes.io ns / pod / SA / uid metadata Informational; ties the token to the pod

The token file lands at /var/run/secrets/eks.amazonaws.com/serviceaccount/token. kubelet re-writes it well before expiry (at ~80% of its lifetime), and a modern SDK re-reads the file on each refresh — which is why you must never cache the token or the STS creds beyond their TTL.

EKS Pod Identity — agent and association

Pod Identity removes the OIDC plumbing. Instead of federating a per-cluster issuer into IAM, you install a node agent and create an association that maps (cluster, namespace, service account) → IAM role. The agent brokers credentials locally; the SDK reaches it through the container-credentials provider.

Part What it is Where it lives Created by
Pod Identity Agent A DaemonSet on every node, listening on 169.254.170.23:80 Kubernetes (kube-system) as an EKS add-on aws eks create-addon --addon-name eks-pod-identity-agent
IAM role + trust Trust is just Service: pods.eks.amazonaws.com IAM You
Permissions policy Same scoped policy as IRSA IAM (attached) You
Pod identity association The (cluster, ns, sa) → role mapping EKS control plane aws eks create-pod-identity-association

When a pod using an associated SA starts, the agent (via a mutating webhook baked into the add-on) injects AWS_CONTAINER_CREDENTIALS_FULL_URI=http://169.254.170.23/v1/credentials and a token file the agent uses to authenticate the pod to itself. The SDK fetches credentials from that endpoint; behind the scenes the agent calls the EKS Auth API (eks-auth:AssumeRoleForPodIdentity) to mint credentials for the associated role and attaches session tags for the cluster, namespace, service account and pod.

Env var injected IRSA Pod Identity
AWS_ROLE_ARN ✅ the role to assume
AWS_WEB_IDENTITY_TOKEN_FILE /var/run/secrets/eks.amazonaws.com/serviceaccount/token
AWS_STS_REGIONAL_ENDPOINTS regional
AWS_CONTAINER_CREDENTIALS_FULL_URI http://169.254.170.23/v1/credentials
AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE ✅ agent token path
AWS_REGION / AWS_DEFAULT_REGION ✅ (both) ✅ (both)

IRSA, wire by wire

Step 1 — the OIDC provider

IRSA begins by making IAM trust your cluster’s OIDC issuer. You need the issuer URL, then you register it once per cluster (it is account-global — reused by every IRSA role in that cluster).

# The cluster's OIDC issuer URL
aws eks describe-cluster --name kloudvin-eks \
  --query 'cluster.identity.oidc.issuer' --output text
# -> https://oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E

# Register it as an IAM OIDC identity provider (eksctl does the thumbprint for you)
eksctl utils associate-iam-oidc-provider \
  --cluster kloudvin-eks --region ap-south-1 --approve

# Verify it now exists in IAM
aws iam list-open-id-connect-providers

The registered provider’s ARN is arn:aws:iam::111122223333:oidc-provider/oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E. That ARN is the Principal.Federated in every IRSA trust policy for this cluster.

OIDC provider field Value Note
Provider URL oidc.eks.<region>.amazonaws.com/id/<hash> The issuer minus https://
Audience (client ID) sts.amazonaws.com Required; the token’s aud must match
Thumbprint SHA-1 of the issuer CA Auto-computed by eksctl/IAM; only matters on private CAs
ARN arn:aws:iam::<acct>:oidc-provider/oidc.eks…/id/<hash> Used as Federated in trust policies
Scope One per cluster, account-global Reused by all IRSA roles for that cluster

Step 2 — the trust policy (the part people get wrong)

The trust policy answers who may assume this role. For IRSA it federates the OIDC provider and pins two conditions. Get either condition wrong and STS returns AccessDenied.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:aud": "sts.amazonaws.com",
        "oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:sub": "system:serviceaccount:apps:s3-reader"
      }
    }
  }]
}
Trust element Correct value Common mistake Result of the mistake
Principal.Federated The exact OIDC provider ARN ARN of a different cluster / not registered Not authorized to perform sts:AssumeRoleWithWebIdentity
Action sts:AssumeRoleWithWebIdentity sts:AssumeRole Assume fails — wrong action for web identity
…:aud condition sts.amazonaws.com Omitted Any authenticated token passes the aud check
…:sub condition system:serviceaccount:<ns>:<sa> Omitted, or wrong ns/sa Any SA in the cluster can assume, or yours can’t
Condition operator StringEquals (exact) StringLike without care Over-broad match (e.g. * lets every SA in)

The :sub condition is the security boundary. Omit it and you have a confused deputy: any pod in the cluster, in any namespace, can assume this role by using its own projected token — because they all pass the aud check. Always pin :sub to the exact system:serviceaccount:<namespace>:<serviceaccount>. Use StringLike with a wildcard only deliberately, e.g. system:serviceaccount:apps:* to let every SA in the apps namespace share a role (rare, and a smell).

Step 3 — the permissions policy

The trust policy said who; the permissions policy says what. Scope it to the exact resource ARNs — one bucket, not s3:*.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "ReadOneBucket",
    "Effect": "Allow",
    "Action": ["s3:GetObject", "s3:ListBucket"],
    "Resource": [
      "arn:aws:s3:::kloudvin-irsa-demo",
      "arn:aws:s3:::kloudvin-irsa-demo/*"
    ]
  }]
}

Note the two ARNs: s3:ListBucket is a bucket-level action (resource = the bucket), s3:GetObject is an object-level action (resource = bucket/*). Miss the bucket-level ARN and aws s3 ls fails while aws s3 cp works — a classic half-scoped policy.

Step 4 — the ServiceAccount annotation

Finally, tell Kubernetes which role this ServiceAccount maps to. The annotation is what the webhook reads to inject AWS_ROLE_ARN.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: s3-reader
  namespace: apps
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/kloudvin-irsa-s3-reader
    # optional: eks.amazonaws.com/sts-regional-endpoints: "true"  (default true)
    # optional: eks.amazonaws.com/token-expiration: "86400"
SA annotation Purpose Default When to change
eks.amazonaws.com/role-arn The role the pod assumes (required) Always set it
eks.amazonaws.com/sts-regional-endpoints Use the regional STS endpoint true Rarely; keep regional for latency/resilience
eks.amazonaws.com/token-expiration Projected token TTL (seconds) 86400 Lower for stricter rotation; kubelet still rotates early

Step 5 — the runtime flow

Before the sequence, know exactly what the admission webhook mutates onto the pod — this is the surface you inspect when injection “didn’t happen”:

Mutation Value Confirm inside the pod
Env AWS_ROLE_ARN The role from the SA annotation env | grep AWS_ROLE_ARN
Env AWS_WEB_IDENTITY_TOKEN_FILE /var/run/secrets/eks.amazonaws.com/serviceaccount/token env | grep TOKEN_FILE
Env AWS_STS_REGIONAL_ENDPOINTS regional env | grep REGIONAL
Env AWS_REGION / AWS_DEFAULT_REGION The cluster region env | grep REGION
Projected volume aws-iam-token (SA token, aud=sts.amazonaws.com) mount | grep eks.amazonaws.com
Volume mount Read-only at the token path cat $AWS_WEB_IDENTITY_TOKEN_FILE

Deploy a pod that references the SA and the machinery runs itself. Here is the exact sequence, and it explains every symptom in the playbook later.

# What happens Where What can break
1 Pod is created referencing SA s3-reader API server → webhook Webhook down → no injection → node role
2 Webhook injects AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE, AWS_STS_REGIONAL_ENDPOINTS, mounts the projected-token volume Admission SA not annotated / pod pre-existing → not injected
3 kubelet writes the projected JWT (aud=sts, sub=ns:sa) to the token file Node Token audience wrong → validation fails
4 App starts; SDK sees web-identity env, reads the token Pod Old SDK ignores env → falls to IMDS (node role)
5 SDK calls sts:AssumeRoleWithWebIdentity with the token STS Trust sub/aud/provider mismatch → AccessDenied
6 STS validates the JWT signature against the OIDC JWKS, checks trust conditions STS Clock skew / unreachable JWKS → InvalidIdentityToken
7 STS returns short-lived creds (~1h) STS → SDK
8 SDK caches creds, refreshes before expiry from the rotating token Pod Old SDK doesn’t refresh → expiry error after ~1h
9 The actual s3:GetObject call is signed with those creds AWS API Permissions policy too narrow → AccessDenied (not STS)

The AssumeRoleWithWebIdentity request/response is the crux; knowing its fields makes the CloudTrail entries readable.

Field Request / response Value on our workload
RoleArn (req) Role to assume arn:aws:iam::111122223333:role/kloudvin-irsa-s3-reader
WebIdentityToken (req) The projected JWT The token file contents
RoleSessionName (req) Session label SDK-generated (e.g. botocore-session-…)
DurationSeconds (req) Credential TTL 3600 default (≤ role max session duration)
Credentials (resp) AccessKeyId/SecretAccessKey/SessionToken Short-lived STS creds
AssumedRoleUser (resp) The resulting principal arn:aws:sts::111122223333:assumed-role/kloudvin-irsa-s3-reader/<session>
SubjectFromWebIdentityToken (resp) Echo of the token sub system:serviceaccount:apps:s3-reader

EKS Pod Identity, wire by wire

Step 1 — install the agent add-on

Pod Identity’s runtime is a DaemonSet. Install it as a managed add-on; it appears in kube-system and must be running on the node your pod lands on.

aws eks create-addon --cluster-name kloudvin-eks \
  --addon-name eks-pod-identity-agent

# Verify the DaemonSet is up on every node
kubectl get daemonset eks-pod-identity-agent -n kube-system
# DESIRED CURRENT READY   ...   should equal your node count

The agent’s runtime facts — useful when creds don’t appear and you’re checking where the SDK is meant to reach:

Agent fact Value Note
Add-on name eks-pod-identity-agent Managed EKS add-on
Runs as DaemonSet in kube-system One agent pod per node (EC2 only)
Credential endpoint (IPv4) 169.254.170.23:80 Link-local; AWS_CONTAINER_CREDENTIALS_FULL_URI path /v1/credentials
Credential endpoint (IPv6) [fd00:ec2::23]:80 For IPv6 clusters
Networking hostNetwork: true The link-local IP lives on the node
Underlying API eks-auth:AssumeRoleForPodIdentity The agent calls this to mint creds
Minimum cluster EKS 1.24+ Older clusters must use IRSA
Fargate Unsupported No node to run the DaemonSet

Step 2 — the (much simpler) trust policy

There is no OIDC provider and no per-cluster hash. The role trusts a single AWS service principal, and it needs sts:TagSession because Pod Identity attaches session tags.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "pods.eks.amazonaws.com" },
    "Action": ["sts:AssumeRole", "sts:TagSession"]
  }]
}

This exact trust policy works on any cluster in the account — nothing in it is cluster-specific. That is Pod Identity’s headline advantage: one role definition, reusable everywhere, no trust edits when you spin up a new cluster.

Step 3 — attach the same permissions policy

The permissions policy is identical to IRSA — the scoped s3:GetObject/s3:ListBucket on one bucket. What the role can do is orthogonal to how the pod assumes it.

Step 4 — create the association

Instead of annotating the SA, you create an association in the EKS control plane. The SA itself needs no annotation — just its plain name and namespace.

aws eks create-pod-identity-association \
  --cluster-name kloudvin-eks \
  --namespace apps \
  --service-account pi-s3-reader \
  --role-arn arn:aws:iam::111122223333:role/kloudvin-pi-s3-reader

# List associations to confirm
aws eks list-pod-identity-associations --cluster-name kloudvin-eks
Association field Purpose Gotcha
--cluster-name Which cluster the mapping applies to Wrong cluster → no creds
--namespace The SA’s namespace Must match the pod’s namespace exactly
--service-account The SA name Must match serviceAccountName exactly
--role-arn The IAM role to broker Role trust must allow pods.eks.amazonaws.com
(session tags) Auto-added: cluster/ns/sa/pod Usable in policies via aws:PrincipalTag (ABAC)

Because the association carries session tags, you can write ABAC policies keyed on aws:PrincipalTag/kubernetes-namespace — grant a role access only to resources tagged with the same namespace, and one role safely serves many teams.

IRSA vs Pod Identity vs node role — the rigorous comparison

Both IRSA and Pod Identity give a pod its own role with short-lived creds; the node role does not. Between the two good options, the choice is about trust model, cross-cluster reuse, cross-account, and where you run (Fargate). This is the table to memorize.

Dimension Node role IRSA EKS Pod Identity
Granularity Per node (shared) Per ServiceAccount Per ServiceAccount
Setup per cluster None (already there) Register OIDC provider once Install agent add-on once
Setup per workload Role + trust (OIDC sub) + SA annotation Role + trust + association (no annotation)
Trust policy n/a Federated OIDC + sub/aud conditions (cluster-specific) Service: pods.eks.amazonaws.com (generic)
Cross-cluster role reuse n/a Trust names one cluster’s provider → edit per cluster Same role, any cluster — no trust change
Cross-account n/a Direct: target-account role trusts the OIDC provider Role chaining (assoc role assumes target)
Rotation Node creds rotate; scope static Short-lived STS creds, auto-refresh Short-lived, auto-refresh
Session tags (ABAC) No Not by default Yes (cluster/ns/sa/pod) automatically
Fargate support n/a Yes No (agent is a DaemonSet — EC2 nodes only)
Self-managed / on-prem (Anywhere) n/a Works via OIDC Newer; check add-on support
CloudTrail identity Node role + instance ID Assumed-role session Assumed-role session + session tags
Minimum plumbing OIDC provider + webhook (managed) Agent add-on
Introduced Always 2019 Late 2023 (GA)

When to pick which

If you… Pick Why
Run pods on Fargate IRSA Pod Identity’s agent can’t run on Fargate
Reuse one role across many clusters Pod Identity Generic trust — no per-cluster OIDC edits
Want the simplest new-workload setup Pod Identity One create-pod-identity-association, no annotation, no OIDC math
Want ABAC by namespace/SA out of the box Pod Identity Session tags are automatic
Assume a role directly in another account IRSA Target-account role can trust the OIDC provider (one hop)
Have an existing, working IRSA estate Keep IRSA (migrate opportunistically) Both can coexist; migrate per workload
Run EKS Anywhere / very old clusters IRSA Broadest support surface
Need application AWS perms at all Either — never the node role The node role is not for app permissions

A pragmatic rule for greenfield EC2-node clusters: default to Pod Identity for its simpler trust and cross-cluster reuse, and reach for IRSA specifically when you’re on Fargate or you need direct cross-account OIDC federation.

Scoping least privilege per workload

The mechanism gets the pod an identity; the permissions policy decides whether that identity is least-privilege. One role per workload, resource-scoped ARNs, no wildcards. A few worked examples:

Workload Actions Resource scope Anti-pattern to avoid
Read one config bucket s3:GetObject, s3:ListBucket bucket + bucket/* s3:* on *
Write to one queue sqs:SendMessage one queue ARN sqs:* on *
Read/write one table dynamodb:GetItem, PutItem, Query one table ARN (+ index ARN) dynamodb:*
Read one secret secretsmanager:GetSecretValue one secret ARN secretsmanager:GetSecretValue on *
Publish one topic sns:Publish one topic ARN sns:*
Decrypt with one key kms:Decrypt one key ARN + kms:ViaService condition kms:* on *

Reinforce scoping with conditions. The most useful keys for pod workloads:

Condition key Use Example Pairs best with
aws:PrincipalTag/kubernetes-namespace ABAC — grant only to the caller’s namespace resource tag must equal the namespace Pod Identity (auto session tags)
aws:PrincipalTag/kubernetes-service-account ABAC per SA fence by SA name Pod Identity
s3:prefix Fence one team into a bucket path ListBucket limited to team-a/* Shared buckets
kms:ViaService Key usable only through one service s3.ap-south-1.amazonaws.com Encrypted S3/EBS
aws:SourceVpce Calls only via a VPC endpoint your interface endpoint ID Private clusters
aws:RequestedRegion Lock the role to one region ap-south-1 Blast-radius control

Validate before you ship with aws iam simulate-principal-policy against the role ARN — it evaluates the effective permissions including boundaries and (in the console policy simulator) SCPs.

Cross-account access from a pod

A pod frequently needs a resource in another account (shared data lake, central logging). There are two patterns, and IRSA and Pod Identity differ here.

Pattern How it works IRSA Pod Identity
Direct OIDC federation Register the cluster’s OIDC provider in the target account; a role there trusts it with the sub condition; the pod’s token assumes it directly (one hop) ✅ supported ✗ (no OIDC to federate)
Role chaining Pod assumes a local role (IRSA or PI); that role has sts:AssumeRole on a cross-account role that trusts it; the app does a second AssumeRole

Direct federation (IRSA) — a role in Account B trusts the cluster’s OIDC provider, registered in B:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::BBBBBBBBBBBB:oidc-provider/oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": { "StringEquals": {
    "oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:sub": "system:serviceaccount:apps:cross-acct-reader",
    "oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:aud": "sts.amazonaws.com"
  }}
}

Role chaining (works for both) — the local role gets permission to assume the remote one, and the app calls sts:AssumeRole a second time. The mechanics of the second hop, and how the two trust policies must agree, are covered in depth in Cross-Account Access with IAM Roles & sts:AssumeRole: A Hands-On Guide. The constraints that bite when you wire this up:

Constraint Direct OIDC federation (IRSA) Role chaining (both)
Hops to the target 1 (token → target role) 2 (local role → target role)
Where the OIDC provider lives Target account Cluster account only
Session duration cap Role max session duration Capped at 1h when chaining
ExternalId in trust Not applicable (OIDC sub is the guard) Optional, on the second hop
Who edits the target trust Target-account admin Target-account admin
Works for Pod Identity

Chained sessions can’t be re-chained past AWS limits and the effective session duration is capped by the shortest hop — plan for one chain, not three.

Architecture at a glance

The diagram traces the real path a pod’s AWS call takes and pins each failure class to the exact hop where it bites. Read it left to right. The pod runs as ServiceAccount s3-reader and is handed a projected OIDC token (aud=sts.amazonaws.com, sub=system:serviceaccount:apps:s3-reader) — crucially it does not use the shared node instance role sitting next to it (badge 1, the wrong answer). The SDK presents that token to STS AssumeRoleWithWebIdentity, which verifies the signature and the sub/aud against the scoped IAM role’s trust policy; the alternative Pod Identity path (the node agent at 169.254.170.23, no OIDC) converges on the same role. STS returns short-lived credentials (~1h, auto-refreshed), and the pod finally calls the AWS API — reading the one allowed S3 bucket and getting AccessDenied 403 on any other, which is the proof that scoping works. Each numbered badge marks a failure class; the legend narrates symptom · confirm · fix.

Amazon EKS pod IAM identity flow shown left to right across five zones: a pod-on-a-node zone with an app pod running as ServiceAccount s3-reader, its projected OIDC token carrying aud=sts.amazonaws.com and sub=system:serviceaccount:apps:s3-reader, and a shared node instance role marked as the wrong answer; an STS-plus-OIDC zone with the AssumeRoleWithWebIdentity call verifying sub and aud and the alternative EKS Pod Identity agent at 169.254.170.23 needing no per-cluster OIDC; a scoped IAM role zone with a trust policy on the OIDC subject or pods.eks.amazonaws.com and a least-privilege policy granting s3:Get on one bucket; a short-lived credentials zone with roughly one-hour auto-refreshed STS credentials; and an AWS API zone where the pod reads the allowed S3 bucket kloudvin-irsa-demo but gets AccessDenied 403 on any other bucket. Six numbered badges mark the node role as the wrong answer, the projected ServiceAccount token, the AssumeRoleWithWebIdentity verification, the Pod Identity alternative, the least-privilege role trust, and the short-lived credentials with the second-bucket denial test.

Real-world scenario

Meridian Retail runs a 40-node EKS cluster (ap-south-1) shared by six squads: checkout, catalog, search, recommendations, fraud, and a platform team. In year one they shipped fast the wrong way — the node group’s instance role accumulated s3:*, dynamodb:*, sqs:*, and secretsmanager:GetSecretValue on *, because “the pods need it.” Every pod on every node had the union. It worked, and nobody looked, until a dependency in the recommendations service shipped a vulnerable image and an attacker got RCE in that container.

The incident review was brutal precisely because of the node role. From the recommendations pod, the attacker curled 169.254.169.254, lifted the node role credentials, and — because the node role was a superset — enumerated and exfiltrated objects from the checkout PII bucket, a workload the recommendations team had no business touching. CloudTrail showed the reads, but the userIdentity was arn:aws:sts::…:assumed-role/eksNodeGroupRole/i-0abc123 — the platform team could not tell which pod had done it without correlating instance-to-pod scheduling by timestamp. Blast radius: the entire node role. Attribution: none.

The remediation was a two-week migration to per-pod identity, and they chose Pod Identity for new work and kept IRSA where it already existed. First they enabled the OIDC provider (it was already on for a legacy IRSA workload) and installed the eks-pod-identity-agent add-on. Then, workload by workload, they created one role per ServiceAccount with a policy scoped to exactly that workload’s resources — checkout’s role could read only the checkout bucket and write only the checkout table; recommendations’ role could read only the recommendations feature store and had no S3 write anywhere. They stripped the node role back to AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly, and the CNI policy, and — the step that closes the door — set the node IMDS hop limit to 1 and required IMDSv2, so a compromised pod can no longer curl the node role at all.

The measurable outcome: the recommendations role, re-tested against the checkout bucket, now returns AccessDenied — the exact test in this article’s lab. CloudTrail entries became assumed-role/checkout-app/… and assumed-role/reco-app/…, so every AWS action is attributable to a named workload. When they later added a second cluster in ap-south-2 for DR, the Pod Identity roles moved over with zero trust-policy edits because the trust is just pods.eks.amazonaws.com — the one place Pod Identity paid off immediately over IRSA, whose trust policies would each have needed the new cluster’s OIDC provider ARN added. The lesson they wrote into their platform runbook: the node role is for the node; every workload gets its own role; and you lock IMDS so “just use the node role” stops being physically possible.

Advantages and disadvantages

IRSA

Advantages Disadvantages
Works everywhere — EC2 nodes and Fargate, EKS Anywhere Per-cluster OIDC provider to register and reason about
Direct cross-account via OIDC federation (one hop) Trust policy is cluster-specific (edit per cluster to reuse a role)
Mature (2019), huge ecosystem, every controller supports it Easy to mis-scope the trust (sub/aud footguns)
No extra DaemonSet on the node The SA annotation is a manual, easy-to-typo step
Standard OIDC — auditable, portable mental model Sharing a role across clusters means multiple providers in one trust

EKS Pod Identity

Advantages Disadvantages
Generic trust (pods.eks.amazonaws.com) — reuse a role across clusters unchanged No Fargate support (agent is a DaemonSet)
Simplest per-workload setup — one association, no annotation, no OIDC Requires the agent add-on running on every node
Session tags (cluster/ns/sa/pod) enable ABAC automatically Newer (2023) — some tools/controllers still assume IRSA
Nothing cluster-specific in IAM to maintain Cross-account is role chaining (no direct OIDC federation)
Association lives in the EKS control plane (visible via API) Agent is one more component to monitor/upgrade

The trade-off that usually decides it: Fargate forces IRSA, and cross-cluster reuse rewards Pod Identity. On a plain EC2-node cluster with no Fargate and a future second cluster, Pod Identity is less to get wrong. If your platform is already all-IRSA and working, there is no urgency to migrate — the two coexist per-workload.

Hands-on lab

You’ll build the same S3-reader pod twice — once with IRSA, once with Pod Identity — prove it can read one bucket and is denied another, then tear everything down. ⚠️ The EKS control plane costs ~$0.10/hour (~$73/month) and is not free-tier — run this on an existing cluster or delete the cluster after. Nodes, if you launch them for this, also cost. S3, STS and Pod Identity add no meaningful charge.

Assumptions: an EKS cluster kloudvin-eks in ap-south-1 with at least one EC2 node group (Pod Identity needs EC2 nodes), kubectl context set, and aws/eksctl installed. Replace 111122223333 with your account ID.

Part 0 — set up the buckets and namespace

export ACCOUNT_ID=111122223333
export REGION=ap-south-1
export CLUSTER=kloudvin-eks

# Two buckets: one the pod may read, one it must be denied
aws s3api create-bucket --bucket kloudvin-irsa-demo \
  --region $REGION --create-bucket-configuration LocationConstraint=$REGION
aws s3api create-bucket --bucket kloudvin-irsa-forbidden \
  --region $REGION --create-bucket-configuration LocationConstraint=$REGION
echo "hello from the allowed bucket" > hello.txt
aws s3 cp hello.txt s3://kloudvin-irsa-demo/hello.txt

kubectl create namespace apps

Part A — IRSA

A1. Enable the OIDC provider (idempotent):

eksctl utils associate-iam-oidc-provider \
  --cluster $CLUSTER --region $REGION --approve
aws iam list-open-id-connect-providers   # confirm it exists

A2. Create the scoped permissions policy:

cat > s3-read-policy.json <<'EOF'
{ "Version": "2012-10-17", "Statement": [{
  "Sid": "ReadOneBucket", "Effect": "Allow",
  "Action": ["s3:GetObject","s3:ListBucket"],
  "Resource": ["arn:aws:s3:::kloudvin-irsa-demo","arn:aws:s3:::kloudvin-irsa-demo/*"] }] }
EOF
aws iam create-policy --policy-name kloudvin-s3-read \
  --policy-document file://s3-read-policy.json

A3. Create the role + trust + SA in one shot with eksctl (it builds the OIDC trust, attaches the policy, and annotates the SA):

eksctl create iamserviceaccount \
  --cluster $CLUSTER --region $REGION \
  --namespace apps --name s3-reader \
  --role-name kloudvin-irsa-s3-reader \
  --attach-policy-arn arn:aws:iam::$ACCOUNT_ID:policy/kloudvin-s3-read \
  --approve

Verify the SA is annotated:

kubectl get sa s3-reader -n apps -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}{"\n"}'
# -> arn:aws:iam::111122223333:role/kloudvin-irsa-s3-reader

A4. Run a pod using that SA (the amazon/aws-cli image is perfect for probing):

kubectl run irsa-test -n apps --image=amazon/aws-cli \
  --overrides='{"spec":{"serviceAccountName":"s3-reader"}}' \
  --command -- sleep 3600
kubectl wait --for=condition=Ready pod/irsa-test -n apps --timeout=60s

A5. Prove the identity and the scoping:

# Who am I? -> the IRSA role, NOT the node role
kubectl exec -n apps irsa-test -- aws sts get-caller-identity
# Arn: arn:aws:sts::111122223333:assumed-role/kloudvin-irsa-s3-reader/botocore-session-...

# Confirm the env the webhook injected
kubectl exec -n apps irsa-test -- env | grep AWS_
# AWS_ROLE_ARN=... AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/serviceaccount/token ...

# Allowed bucket -> works
kubectl exec -n apps irsa-test -- aws s3 ls s3://kloudvin-irsa-demo
# 2026-07-14 ... hello.txt

# Forbidden bucket -> AccessDenied (this is the win)
kubectl exec -n apps irsa-test -- aws s3 ls s3://kloudvin-irsa-forbidden
# An error occurred (AccessDenied) when calling the ListObjectsV2 operation: Access Denied

A6. Terraform equivalent (OIDC data source → role with a web-identity trust → policy → annotated SA):

data "aws_eks_cluster" "this" { name = "kloudvin-eks" }

locals {
  oidc = replace(data.aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")
}

data "aws_iam_openid_connect_provider" "eks" { url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer }

data "aws_iam_policy_document" "trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.eks.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.oidc}:sub"
      values   = ["system:serviceaccount:apps:s3-reader"]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.oidc}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "irsa" {
  name               = "kloudvin-irsa-s3-reader"
  assume_role_policy = data.aws_iam_policy_document.trust.json
}

resource "aws_iam_role_policy" "s3" {
  role   = aws_iam_role.irsa.id
  policy = jsonencode({ Version = "2012-10-17", Statement = [{
    Effect = "Allow", Action = ["s3:GetObject","s3:ListBucket"],
    Resource = ["arn:aws:s3:::kloudvin-irsa-demo","arn:aws:s3:::kloudvin-irsa-demo/*"] }] })
}

resource "kubernetes_service_account" "s3_reader" {
  metadata {
    name      = "s3-reader"
    namespace = "apps"
    annotations = { "eks.amazonaws.com/role-arn" = aws_iam_role.irsa.arn }
  }
}

Part B — EKS Pod Identity

B1. Install the agent add-on:

aws eks create-addon --cluster-name $CLUSTER --addon-name eks-pod-identity-agent
kubectl get daemonset eks-pod-identity-agent -n kube-system   # READY == node count

B2. Create a role with the Pod Identity trust and attach the SAME policy:

cat > pi-trust.json <<'EOF'
{ "Version": "2012-10-17", "Statement": [{
  "Effect": "Allow",
  "Principal": { "Service": "pods.eks.amazonaws.com" },
  "Action": ["sts:AssumeRole","sts:TagSession"] }] }
EOF
aws iam create-role --role-name kloudvin-pi-s3-reader \
  --assume-role-policy-document file://pi-trust.json
aws iam attach-role-policy --role-name kloudvin-pi-s3-reader \
  --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/kloudvin-s3-read

B3. Create the association and a plain (un-annotated) SA + pod:

kubectl create serviceaccount pi-s3-reader -n apps   # NOTE: no annotation

aws eks create-pod-identity-association \
  --cluster-name $CLUSTER --namespace apps \
  --service-account pi-s3-reader \
  --role-arn arn:aws:iam::$ACCOUNT_ID:role/kloudvin-pi-s3-reader

kubectl run pi-test -n apps --image=amazon/aws-cli \
  --overrides='{"spec":{"serviceAccountName":"pi-s3-reader"}}' \
  --command -- sleep 3600
kubectl wait --for=condition=Ready pod/pi-test -n apps --timeout=60s

B4. Prove it — note the different env and session:

kubectl exec -n apps pi-test -- aws sts get-caller-identity
# Arn: arn:aws:sts::111122223333:assumed-role/kloudvin-pi-s3-reader/eks-kloudvin-eks-...

kubectl exec -n apps pi-test -- 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

kubectl exec -n apps pi-test -- aws s3 ls s3://kloudvin-irsa-demo         # works
kubectl exec -n apps pi-test -- aws s3 ls s3://kloudvin-irsa-forbidden    # AccessDenied

B5. Terraform equivalent (add-on → role with pods.eks… trust → policy → association):

resource "aws_eks_addon" "pod_identity" {
  cluster_name = "kloudvin-eks"
  addon_name   = "eks-pod-identity-agent"
}

data "aws_iam_policy_document" "pi_trust" {
  statement {
    actions = ["sts:AssumeRole", "sts:TagSession"]
    principals { type = "Service"  identifiers = ["pods.eks.amazonaws.com"] }
  }
}

resource "aws_iam_role" "pi" {
  name               = "kloudvin-pi-s3-reader"
  assume_role_policy = data.aws_iam_policy_document.pi_trust.json
}

resource "aws_iam_role_policy_attachment" "pi_s3" {
  role       = aws_iam_role.pi.name
  policy_arn = aws_iam_policy.s3_read.arn
}

resource "aws_eks_pod_identity_association" "s3" {
  cluster_name    = "kloudvin-eks"
  namespace       = "apps"
  service_account = "pi-s3-reader"
  role_arn        = aws_iam_role.pi.arn
}

Part C — teardown

# Pods
kubectl delete pod irsa-test pi-test -n apps

# Pod Identity association + role
ASSOC=$(aws eks list-pod-identity-associations --cluster-name $CLUSTER \
  --query 'associations[?serviceAccount==`pi-s3-reader`].associationId' --output text)
aws eks delete-pod-identity-association --cluster-name $CLUSTER --association-id $ASSOC
aws iam detach-role-policy --role-name kloudvin-pi-s3-reader \
  --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/kloudvin-s3-read
aws iam delete-role --role-name kloudvin-pi-s3-reader
# Optional: aws eks delete-addon --cluster-name $CLUSTER --addon-name eks-pod-identity-agent

# IRSA (eksctl deletes the CloudFormation stack incl. the role + SA)
eksctl delete iamserviceaccount --cluster $CLUSTER --namespace apps --name s3-reader

# Shared policy + buckets + namespace
aws iam delete-policy --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/kloudvin-s3-read
aws s3 rb s3://kloudvin-irsa-demo --force
aws s3 rb s3://kloudvin-irsa-forbidden --force
kubectl delete namespace apps

⚠️ The OIDC provider (from associate-iam-oidc-provider) is safe to leave — it’s free and shared. Delete the cluster separately if you spun one up only for this lab; that is the line item that costs money.

Common mistakes & troubleshooting

Almost every IRSA/Pod Identity ticket is one of the classes below. The first move is always the same: run kubectl exec <pod> -- aws sts get-caller-identity and read the Arn. It tells you instantly whether you’re using the right role, the node role, or failing to assume at all.

# Symptom Root cause Confirm (exact command) Fix
1 Pod uses the node role, not the SA role Webhook didn’t inject (SA annotation typo, or pod created before annotation) kubectl exec … -- env | grep AWS_ROLE_ARN is empty; get-caller-identity shows assumed-role/<nodegroup-role>/i-… Fix eks.amazonaws.com/role-arn on the SA; recreate the pod (kubectl rollout restart) — webhook only mutates at creation
2 Pod uses node role, env is set SDK too old to read AWS_WEB_IDENTITY_TOKEN_FILE env shows the vars but get-caller-identity still shows node role Upgrade the SDK (boto3 ≥1.9.x era, Go SDK v2, JS v2/v3, Java v2)
3 AccessDenied … not authorized to perform: sts:AssumeRoleWithWebIdentity Trust :sub mismatch (wrong namespace or SA name) Decode the token sub; compare to trust: aws iam get-role --role-name … --query 'Role.AssumeRolePolicyDocument' Set :sub to the exact system:serviceaccount:<ns>:<sa>
4 Same AccessDenied, :sub looks right Trust :aud missing or wrong (not sts.amazonaws.com) Check the aud condition in the trust policy Add …:aud = sts.amazonaws.com (StringEquals)
5 Not authorized … AssumeRoleWithWebIdentity, trust conditions correct Wrong OIDC provider ARN in Principal.Federated (different cluster / not registered) aws iam list-open-id-connect-providers; diff the hash vs the cluster issuer Register the correct provider; fix the Federated ARN
6 No OpenIDConnect provider found in your account for … OIDC provider never registered in IAM aws iam list-open-id-connect-providers returns nothing matching eksctl utils associate-iam-oidc-provider --cluster … --approve
7 InvalidIdentityToken: Couldn't retrieve verification key … STS can’t fetch the JWKS, or clock skew on the node date on the node vs real time; STS reachability Fix NTP/chrony; ensure regional STS reachable (VPC endpoint if private)
8 InvalidIdentityToken / token rejected Token audience mismatch or expired projected token Decode the token aud/exp inside the pod Ensure projection audience: sts.amazonaws.com; recreate pod
9 Pod Identity: no creds at all Agent add-on missing or not running on that node kubectl get ds eks-pod-identity-agent -n kube-system; is a pod on this node? aws eks create-addon --addon-name eks-pod-identity-agent
10 Pod Identity: still no creds, agent healthy Association namespace/SA mismatch aws eks list-pod-identity-associations … vs kubectl get pod -o jsonpath='{.spec.serviceAccountName}' Recreate the association with the exact ns + SA; restart the pod
11 Pod Identity on Fargate: no creds Agent is a DaemonSet — can’t run on Fargate Is the pod on a Fargate profile? kubectl get pod -o wide (node = fargate-…) Use IRSA for Fargate workloads
12 Cross-account AssumeRole denied Target role trust doesn’t allow the source (no OIDC in target / missing chaining perm) Read the target role trust; CloudTrail in the target account Register the OIDC provider in the target acct or grant sts:AssumeRole on the local role + chain
13 Creds expire mid-run (ExpiredToken after ~1h) Very old SDK not refreshing web-identity creds / token file not re-read SDK version; error appears ~1h after start Upgrade the SDK — modern ones re-read the rotating token automatically
14 AccessDenied on the actual S3/DynamoDB call (role assumed fine) Assume succeeded but the permissions policy is too narrow / wrong ARN get-caller-identity shows the right role; aws iam simulate-principal-policy the role Fix the permissions policy (not the trust) — add the correct action/resource ARN
15 Pod can still reach the node role via IMDS IMDS not locked down; app curls 169.254.169.254 deliberately kubectl exec … -- curl -s http://169.254.169.254/latest/meta-data/iam/… returns the node role Set node IMDS hop limit = 1, require IMDSv2; block 169.254.169.254 egress from pods

The STS / error-string reference

When the assume fails, the exact string pins the layer. Keep this next to the playbook.

Error string Layer Likely cause Fix
Not authorized to perform sts:AssumeRoleWithWebIdentity Trust policy :sub/:aud mismatch, wrong action Align trust conditions to the token
No OpenIDConnect provider found in your account for … IAM provider Provider not registered / wrong URL Register the cluster’s OIDC provider
InvalidIdentityToken: Couldn't retrieve verification key STS ↔ JWKS JWKS unreachable (private cluster) or clock skew Route to STS/JWKS; fix NTP
InvalidIdentityToken (audience) Token aud ≠ registered audience Fix projection audience sts.amazonaws.com
ExpiredTokenException Creds STS creds past TTL, SDK didn’t refresh Upgrade SDK; don’t cache creds
AccessDenied (on S3/DDB, not STS) Permissions policy Action/resource not allowed Fix the permissions policy, not the trust
Unable to locate credentials / NoCredentialProviders SDK chain Nothing in the chain produced creds (IMDS blocked, no IRSA/PI) Ensure IRSA/PI injected; or intentionally none
WebIdentityErr (Go) / botocore … AssumeRoleWithWebIdentity (py) SDK Web-identity provider raised Read the wrapped STS error above
403 calling 169.254.170.23 (Pod Identity) Agent Token file mismatch / agent auth Recreate pod; check the agent add-on version

Decision table — which identity am I actually using?

Read the Arn from aws sts get-caller-identity and jump straight to the cause:

get-caller-identity Arn looks like… It’s probably… Do this
assumed-role/<nodegroup-role>/i-0abc… Node role — IRSA/PI not active Check env injection (rows 1–2); recreate the pod
assumed-role/<your-irsa-role>/botocore-session-… IRSA working Nothing — if the app still fails it’s a permissions issue (row 14)
assumed-role/<your-pi-role>/eks-<cluster>-… Pod Identity working Nothing — permissions issue if the call still fails
An AccessDenied before any call Assume failed — trust problem Rows 3–6 (trust sub/aud/provider)
Unable to locate credentials Chain produced nothing IMDS blocked and no IRSA/PI — inject one

The three nastiest failures, in full

The pod that keeps using the node role. You annotated the SA, but get-caller-identity still shows the node role. Ninety percent of the time the pod was created before the annotation (or before the SA existed) — the webhook only mutates pods at admission, so an existing pod never got the env vars or the token mount. kubectl rollout restart deployment/<name> (or delete the bare pod) and it’s fixed. The other cause is a typo in the annotation key — it must be exactly eks.amazonaws.com/role-arn; a wrong domain or a trailing space means the webhook sees no annotation and skips injection silently. Confirm which by kubectl exec -- env | grep AWS_ROLE_ARN: empty means “not injected” (annotation/restart), populated-but-still-node-role means “SDK too old” (upgrade).

AccessDenied from AssumeRoleWithWebIdentity with a correct-looking sub. The token’s sub is system:serviceaccount:apps:s3-reader, and your trust says the same — yet it’s denied. Two subtle causes dominate. First, namespace drift: the SA is annotated in apps but the pod runs in apps-staging, so the real token sub is …:apps-staging:s3-reader and the trust’s apps doesn’t match. Always compare the live token, not the manifest. Second, a missing :aud paired with a StringLike :sub wildcard someone added “to make it work” — which quietly widened the trust to every SA. Decode the actual token (kubectl exec -- cat $AWS_WEB_IDENTITY_TOKEN_FILE and base64-decode the middle segment) and diff sub/aud against the trust policy character for character.

Pod Identity works on some nodes, not others. New pods on some nodes get creds; on others they fall to no-creds or the node role. The agent is a DaemonSet, so the culprit is almost always a node where the agent pod isn’t Ready — a node that came up before the add-on, a node with a taint the agent doesn’t tolerate, or a Fargate pod (where the agent can’t run at all). kubectl get pods -n kube-system -l app.kubernetes.io/name=eks-pod-identity-agent -o wide and line the node names up against where your workload lands. Fix: ensure the DaemonSet tolerates your node taints and covers every node group; move Fargate workloads to IRSA.

Best practices

Security notes

The threat model for pod identity is: stop one compromised pod from becoming account-wide access. Each control below closes a specific path.

Control What it stops How
Per-pod role (IRSA/PI) Lateral blast radius A compromised pod has only its scoped policy, not the node’s union
IMDSv2 + hop limit 1 Node-role theft via IMDS Pods can’t reach 169.254.169.254; SSRF can’t hop to metadata
:sub condition Confused deputy Only the named SA can assume — not every SA in the cluster
:aud condition Token misuse Only tokens minted for STS are accepted
Resource-scoped policy Over-broad data access Role touches only the named ARNs
Session tags + ABAC (PI) Cross-tenant access Policy keyed on kubernetes-namespace fences tenants
Short-lived creds Long-lived key leakage Nothing durable to steal; creds expire in ~1h
kms:ViaService / conditions Key misuse Keys usable only through the intended service
Deny long-lived keys (SCP/policy) Baked-in IAM user keys Force IRSA/PI; block iam:CreateAccessKey broadly
Namespace-scoped SAs + RBAC Pod impersonating another SA Kubernetes RBAC controls who can create pods with which SA

Two subtleties worth internalizing. First, the trust policy is the real access-control decision — the permissions policy only matters after a principal is allowed to assume. An over-broad trust (missing :sub) is a bigger hole than an over-broad permissions policy, because it lets the wrong workload in. Second, IRSA and Pod Identity remove long-lived secrets from the cluster entirely — there is no access key in a Kubernetes Secret to leak in kubectl get secret -o yaml, land in an etcd backup, or get committed to Git. That property alone justifies the migration off node-role/access-key patterns.

Cost & sizing

IRSA and Pod Identity themselves are free — there is no charge for the OIDC provider, the webhook, the Pod Identity agent add-on, AssumeRoleWithWebIdentity, or AssumeRoleForPodIdentity. What costs money is the cluster underneath them.

Item Cost Notes
EKS control plane ~$0.10/hour (~$73/month) per cluster (standard support) The main line item; extended-support versions cost more
EC2 worker nodes Per instance-hour You pay for the nodes regardless of identity mechanism
Fargate pods Per vCPU/GB-hour IRSA only (no Pod Identity on Fargate)
IRSA / OIDC provider $0 No charge to register or use
Pod Identity agent add-on $0 Runs on your nodes; uses trivial CPU/memory
AssumeRoleWithWebIdentity / AssumeRoleForPodIdentity $0 STS/EKS-auth calls are not billed
S3 (lab) Cents GetObject/ListBucket on a few objects
CloudTrail Management events free (first copy) Data events (S3 object-level) cost extra if enabled

Sizing note: the Pod Identity agent consumes only a few millicores and tens of MB per node — negligible. The real “sizing” decision is cluster count: because IRSA trust is per-cluster, a fleet of clusters multiplies the trust-policy maintenance, whereas Pod Identity’s generic trust scales to N clusters with the same roles — an operational cost saving, not a dollar one. Rough INR framing: a single always-on EKS control plane is about ₹6,000/month before nodes, so tear the lab cluster down if you created it just for this.

Interview & exam questions

Q1. Why is attaching application permissions to the node instance role a bad idea? Because the node role is shared by every pod on the node, so all of them inherit the permission — there is no per-workload least privilege, and CloudTrail attributes calls to the node role and instance, not the pod. A single compromised container gets the union of everything the node role allows. (SAA-C03 / SCS-C02)

Q2. What are the four moving parts of IRSA? The cluster OIDC provider registered in IAM; an IAM role whose trust policy federates that provider with sub/aud conditions; a scoped permissions policy; and a ServiceAccount annotated with eks.amazonaws.com/role-arn. At runtime the webhook injects env vars and the SDK calls sts:AssumeRoleWithWebIdentity. (DVA-C02)

Q3. What does the IRSA trust policy’s sub condition contain, and why is it critical? system:serviceaccount:<namespace>:<serviceaccount>. It pins which Kubernetes ServiceAccount may assume the role. Omit it and any SA in the cluster — passing only the aud check — can assume the role (a confused-deputy hole). (SCS-C02)

Q4. What API call does an IRSA pod make, and what does it exchange? sts:AssumeRoleWithWebIdentity, exchanging the projected ServiceAccount token (an OIDC JWT with aud=sts.amazonaws.com) for short-lived role credentials. STS validates the JWT against the cluster’s OIDC JWKS and the trust conditions. (DVA-C02)

Q5. How does EKS Pod Identity differ from IRSA in setup? Pod Identity needs no per-cluster OIDC provider and no SA annotation — you install the Pod Identity Agent add-on once and create a pod identity association mapping (cluster, ns, sa) → role. The role’s trust is simply Service: pods.eks.amazonaws.com. (SAA-C03)

Q6. Why can’t you use EKS Pod Identity on Fargate? The Pod Identity Agent runs as a DaemonSet on EC2 nodes; Fargate has no node to schedule it on. Use IRSA for Fargate workloads. (SAA-C03)

Q7. A pod’s aws sts get-caller-identity returns the node role ARN. Name two causes. The pod was created before the SA was annotated (webhook injects only at creation — recreate the pod), or the SDK is too old to read AWS_WEB_IDENTITY_TOKEN_FILE and falls back to IMDS. A third is an annotation typo. (SOA-C02)

Q8. You get AccessDenied on AssumeRoleWithWebIdentity, but the actual S3 policy looks fine. Where do you look? The trust policy, not the permissions policy — specifically the :sub (namespace/SA) and :aud conditions and the OIDC provider ARN. The permissions policy only matters after the assume succeeds. (SCS-C02)

Q9. What does InvalidIdentityToken usually indicate? The projected token failed STS validation — commonly clock skew on the node, an unreachable JWKS (private cluster with no route to STS), or an audience mismatch. Fix NTP, ensure STS reachability, and confirm aud=sts.amazonaws.com. (SOA-C02)

Q10. How do you scope an IRSA role to exactly one S3 bucket for reads? Grant s3:GetObject on arn:aws:s3:::bucket/* and s3:ListBucket on arn:aws:s3:::bucket (two ARNs — object-level vs bucket-level). Wildcards or a single ARN produce a half-working or over-broad policy. (DVA-C02)

Q11. How would a pod assume a role in another AWS account? With IRSA, register the cluster’s OIDC provider in the target account and have a role there trust it with the sub condition — a direct one-hop AssumeRoleWithWebIdentity. Alternatively (and required for Pod Identity), role-chain: assume a local role, which then assumes the cross-account role. (SAA-C03)

Q12. Why lock down IMDS on EKS nodes, and how? So a compromised pod can’t curl 169.254.169.254 and steal the node role. Require IMDSv2 and set the instance metadata hop limit to 1, which prevents containers (one network hop away) from reaching the metadata endpoint. (SCS-C02)

Quick check

  1. You attach dynamodb:* to the node role so one pod can use DynamoDB. What have you actually granted, and to whom?
  2. In an IRSA trust policy, what exact string goes in the :sub condition, and what happens if you omit it?
  3. Which API does an IRSA pod call, and which does the Pod Identity agent call on the pod’s behalf?
  4. Your pod’s get-caller-identity shows the node role even though the SA is annotated. What’s the most likely fix?
  5. You need pod identity on a Fargate profile. IRSA or Pod Identity — and why?

Answers

  1. You granted every pod on that node full DynamoDB access, attributed in CloudTrail to the node role and instance ID — not the pod. It’s over-privileged and unauditable; use a per-pod role instead.
  2. system:serviceaccount:<namespace>:<serviceaccount> — it pins which ServiceAccount may assume the role. Omit it and any SA in the cluster (passing only the aud check) can assume the role — a confused-deputy vulnerability.
  3. The IRSA pod calls sts:AssumeRoleWithWebIdentity directly. For Pod Identity, the pod fetches creds from the agent, which calls eks-auth:AssumeRoleForPodIdentity behind the scenes.
  4. Recreate the pod (kubectl rollout restart / delete it) — the webhook injects the env vars and token mount only at pod creation, so a pod that predated the annotation never got them. (Also check for an annotation typo and an old SDK.)
  5. IRSA — the Pod Identity Agent is a DaemonSet and can’t run on Fargate, so associations produce no credentials there. IRSA’s token projection works on Fargate.

Glossary

Next steps

AWSEKSIRSAPod IdentityIAMOIDCKubernetesSTS
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading