Containers

IAM Roles for Service Accounts (IRSA) on Amazon EKS: The Complete Guide

Baking AWS access keys into container images was acceptable in 2017. In 2026 it is career-ending. One leaked image on a public registry, one curious intern running docker history, one misconfigured CI artifact, and suddenly your billing dashboard looks like someone mined a lot of Monero on your dime. The fix has existed for years, yet plenty of production EKS clusters still ship with hardcoded credentials because the alternative was poorly understood.

Original content from computingforgeeks.com - post 165591

IAM Roles for Service Accounts (IRSA) solves this by wiring Kubernetes ServiceAccount tokens directly into the AWS STS trust chain. The pod gets a short-lived JWT, AWS trades it for temporary IAM credentials, and no long-lived secret ever lives inside the container. This guide walks through how IRSA actually works under the hood, how to set it up from scratch, three production patterns (AWS Load Balancer Controller, External Secrets Operator, Karpenter), cross-account role assumption with External ID, a cluster-wide audit technique, the five errors you will definitely hit, and how IRSA compares to the newer EKS Pod Identity feature so you know when to pick each one.

Tested April 2026 on Amazon EKS 1.33 with eksctl 0.225, AWS CLI v2.22, and Amazon Linux 2023 worker nodes

IRSA vs EKS Pod Identity: Which Should You Use?

Before touching any YAML, settle this question. AWS shipped EKS Pod Identity in November 2023 as the eventual replacement for IRSA, and for most new EC2-based clusters it is now the recommended default. Pod Identity is simpler (no per-cluster OIDC provider, no 100-provider-per-account limit, reusable roles across clusters) and faster to set up. So why are we writing a complete IRSA guide in 2026?

Because IRSA is still the only option for several real-world scenarios, and because the entire IAM/OIDC/STS machinery underneath it is foundational knowledge. If you work with EKS, you will touch IRSA whether you like it or not. Here is the honest decision matrix based on what actually ships in EKS today.

ScenarioUse
New EC2-based EKS cluster, greenfieldEKS Pod Identity
Fargate workloadsIRSA only (Pod Identity Agent needs a node DaemonSet)
Direct cross-account role assumptionIRSA (Pod Identity requires role chaining)
Self-managed Kubernetes, EKS Anywhere, OpenShift on AWSIRSA only
Existing production setup already on IRSAKeep IRSA, no rush to migrate
Reuse same IAM role across many clustersPod Identity
Hit the 100-OIDC-provider-per-account limitPod Identity
Need ABAC with session tagsPod Identity (native support)

The rest of this guide focuses on IRSA because it is still the right choice for many production scenarios and because understanding it is foundational knowledge even if you eventually migrate to Pod Identity. If you have already decided to go with Pod Identity, read the mechanics sections anyway. The trust model is nearly identical and the troubleshooting instincts transfer.

One practical note on migration planning. If you are running a cluster with dozens of IRSA workloads and you want to move to Pod Identity, you do not need a big-bang switch. Pod Identity and IRSA coexist cheerfully on the same cluster, on the same namespace, even on the same pod (though you should never do that last one because it is confusing). The usual migration path is to move one workload at a time during its normal deployment cycle, verify it works, and only decommission the old IAM role after the rollout settles. There is no rush, because IRSA is not deprecated and has no published end-of-life date.

How IRSA Actually Works

Most guides wave hands at “IRSA uses OIDC” and move on. That is not enough. The reason people struggle with IRSA errors is that they do not understand which component is failing. Three distinct primitives are in play, and any of the three can break in isolation.

Primitive 1: The OIDC identity provider

Every EKS cluster publishes a JWKS endpoint containing the public keys used to sign ServiceAccount tokens. The AWS console surfaces this as OpenID Connect provider URL under Cluster info > Overview > Details, formatted like https://oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC, where the trailing ID is unique per cluster. On its own, this endpoint is just JSON sitting on HTTPS. It becomes useful only when you register it as an IAM OIDC Identity Provider in your AWS account, which is a one-time action per cluster.

AWS EKS console showing the OpenID Connect provider URL for an EKS cluster under Cluster info Overview Details
The OpenID Connect provider URL shown in the EKS console. Each cluster gets a unique issuer ID in the URL path.

Once registered, IAM knows it can verify tokens signed by that cluster. Without this registration step, every IRSA call returns InvalidIdentityToken: No OpenIDConnect provider found in your account.

Primitive 2: Projected ServiceAccount tokens

Kubernetes 1.12 introduced projected ServiceAccount tokens, which are JWTs with a configurable audience and a short, bounded lifetime. Unlike legacy secret-based tokens, projected tokens are generated on demand by the API server, mounted into the pod by kubelet, and rotated automatically as they approach expiry. The default TTL on EKS is 24 hours and kubelet refreshes the file at roughly 80% of that window (around 19 hours in).

Here is what the JWT payload actually looks like once base64-decoded from a real pod on our test cluster:

{
    "aud": ["sts.amazonaws.com"],
    "exp": 1775899819,
    "iat": 1775813419,
    "iss": "https://oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC",
    "kubernetes.io": {
        "namespace": "demo",
        "pod": {
            "name": "s3-test"
        },
        "serviceaccount": {
            "name": "s3-reader"
        }
    },
    "sub": "system:serviceaccount:demo:s3-reader"
}

Every field in that payload earns its keep. The aud claim is pinned to sts.amazonaws.com, which STS will check before accepting the token. The iss claim matches exactly what IAM has registered as the OIDC provider URL. The sub claim is the canonical system:serviceaccount:<namespace>:<sa-name> string, and this is the value your trust policy will pin against. The gap between iat and exp is exactly 86400 seconds (24 hours), which is the default EKS token lifetime.

Primitive 3: STS AssumeRoleWithWebIdentity

STS exposes an API called AssumeRoleWithWebIdentity that trades a third-party JWT for temporary AWS credentials. When the AWS SDK inside your pod sees the environment variables AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE, it automatically reads the token from disk, calls sts:AssumeRoleWithWebIdentity, and caches the resulting temporary credentials. Default session duration is one hour, configurable up to twelve on the role.

The glue between these primitives is the pod-identity-webhook, a mutating admission webhook that AWS manages on every EKS cluster. On each pod creation, the webhook inspects the pod’s ServiceAccount. If the SA carries an eks.amazonaws.com/role-arn annotation, the webhook rewrites the pod spec to inject the relevant environment variables and mount a projected token volume at /var/run/secrets/eks.amazonaws.com/serviceaccount/. The source is open on GitHub, and reading it will teach you more about IRSA in an hour than most blog posts.

These are the exact environment variables you can expect to find inside an IRSA-enabled pod:

AWS_ROLE_ARN=arn:aws:iam::123456789012:role/eks-irsa-lab-demo-s3-reader-role
AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/serviceaccount/token
AWS_STS_REGIONAL_ENDPOINTS=regional
AWS_DEFAULT_REGION=eu-west-1
AWS_REGION=eu-west-1

The SDK does the rest. Your application code just calls boto3.client('s3') or the Go equivalent and the credential provider chain picks up the web identity transparently. You never write an AssumeRoleWithWebIdentity call yourself unless you are doing something exotic like cross-account chaining, which we cover later.

One subtlety worth calling out. The credential provider chain tries web identity before IMDS, which is exactly what you want. But if the web identity lookup fails for any reason (file missing, JWT expired, STS rejects the call), the SDK falls back to IMDS and silently uses the EC2 node instance profile instead. This is why a broken IRSA setup often looks like “my pod works, just with the wrong permissions” rather than a loud failure. The audit techniques in section 10 catch this, and the IMDS hop-limit hardening in section 12 closes the loophole entirely.

Prerequisites

  • AWS account with IAM admin permissions (or at minimum: iam:CreateRole, iam:CreatePolicy, iam:AttachRolePolicy, iam:CreateOpenIDConnectProvider)
  • An EKS cluster running Kubernetes 1.29 or later. Examples here use EKS 1.33.8
  • eksctl 0.175 or later (tested with 0.225)
  • kubectl 1.29 or later, configured for the target cluster
  • AWS CLI v2 installed and authenticated. Tested with v2.22
  • Helm 3 for the AWS Load Balancer Controller and External Secrets Operator sections
  • Tested on: EKS 1.33 control plane, Amazon Linux 2023 worker nodes, Kubernetes 1.33.8

If you are new to Kubernetes ServiceAccounts and RBAC, the RBAC and ServiceAccounts primer will give you the Kubernetes-side vocabulary this guide assumes you already have.

Enable the OIDC Provider

This is a one-time setup per cluster. Two paths depending on whether the cluster already exists.

For a brand new cluster, pass --with-oidc at creation time and eksctl will register the provider for you:

eksctl create cluster --name=irsa-lab --region=eu-west-1 --version=1.33 --with-oidc --nodes=2 --node-type=t3.medium

For an existing cluster that was created without OIDC (common for clusters built before 2020), a single command retrofits it:

eksctl utils associate-iam-oidc-provider --cluster=irsa-lab --region=eu-west-1 --approve

Verify that the cluster exposes an OIDC issuer URL:

aws eks describe-cluster --name irsa-lab --region eu-west-1 --query 'cluster.identity.oidc.issuer' --output text

The output is the unique issuer URL that every trust policy will reference:

https://oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC

Now confirm IAM knows about it:

aws iam list-open-id-connect-providers

You should see an entry whose ARN ends with the same ID shown by the previous command. If the list is empty, the OIDC association step did not run. Rerun it before moving on.

Hello IRSA: Pod Reading from S3

Time to wire everything together end to end. The goal for this walkthrough is a pod that lists and downloads objects from an S3 bucket without a single hardcoded credential. We will do it manually first so the machinery is visible, then show the eksctl one-liner that compresses the whole dance into a single command.

Create the S3 bucket and a test object

Create a bucket in the same region as your cluster. Same-region access is faster and avoids egress charges during testing:

aws s3api create-bucket --bucket my-app-bucket --region eu-west-1 --create-bucket-configuration LocationConstraint=eu-west-1

Drop a tiny object into it so there is something to list:

echo "hello from irsa" | aws s3 cp - s3://my-app-bucket/hello.txt

Create the IAM permissions policy

Save the read-only S3 policy to a file. This policy grants the minimum needed: list the bucket and download any object within it. Anything broader violates least privilege.

vim s3-read-policy.json

Paste the following JSON into the editor (or pipe it in from your tool of choice):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::my-app-bucket",
        "arn:aws:s3:::my-app-bucket/*"
      ]
    }
  ]
}

Register the policy with IAM:

aws iam create-policy --policy-name irsa-lab-s3-read-policy --policy-document file://s3-read-policy.json

Note the ARN that comes back. You will need it in a moment.

Write the trust policy

The trust policy is the critical part. It is what tells IAM that a specific ServiceAccount in a specific namespace on a specific cluster is allowed to assume this role. Save it to a file:

vim trust-policy.json

Paste the trust policy shown below. Replace 123456789012 with your actual account ID. The OIDC issuer suffix must match the one from the describe-cluster command earlier.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC:sub": "system:serviceaccount:demo:s3-reader",
          "oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}

Two details in that policy deserve scrutiny. The :sub condition pins the role to exactly one ServiceAccount (s3-reader in namespace demo). Without this pin, any ServiceAccount on the cluster could assume the role, which defeats the purpose. The :aud condition forces the token audience to be sts.amazonaws.com, which blocks a subtle attack where a token minted for a different audience could be replayed.

Create the role with this trust policy:

aws iam create-role --role-name eks-irsa-lab-demo-s3-reader-role --assume-role-policy-document file://trust-policy.json

Attach the permissions policy to the new role:

aws iam attach-role-policy --role-name eks-irsa-lab-demo-s3-reader-role --policy-arn arn:aws:iam::123456789012:policy/irsa-lab-s3-read-policy

Create the Kubernetes ServiceAccount

Create a namespace for the demo workload:

kubectl create namespace demo

Now save the ServiceAccount manifest to a file. The eks.amazonaws.com/role-arn annotation is the magic that the webhook looks for.

vim s3-reader-sa.yaml

Paste this YAML into the file:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: s3-reader
  namespace: demo
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eks-irsa-lab-demo-s3-reader-role

Adapt the annotation value to your environment. The format is arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME, where ACCOUNT_ID is your 12-digit AWS account ID (get it with aws sts get-caller-identity --query Account --output text) and ROLE_NAME is the IAM role you created in the previous step. The name and namespace fields must match exactly what you used in the trust policy’s :sub condition. If any of those three values drift (account, role name, or system:serviceaccount:<namespace>:<name>), the pod will get an AccessDenied error when it tries to call AWS APIs.

Apply the manifest:

kubectl apply -f s3-reader-sa.yaml

Launch the test pod

Save a simple pod manifest that uses the AWS CLI image and sleeps so you can exec into it:

vim s3-test-pod.yaml

Paste the pod spec:

apiVersion: v1
kind: Pod
metadata:
  name: s3-test
  namespace: demo
spec:
  serviceAccountName: s3-reader
  containers:
  - name: awscli
    image: amazon/aws-cli:2.22.4
    command: ["sleep", "3600"]

Create the pod:

kubectl apply -f s3-test-pod.yaml

Check that the pod-identity-webhook actually fired by dumping AWS-related env vars inside the container:

kubectl exec -n demo s3-test -- env | grep AWS_

You should see the five variables we covered in the mechanics section, with AWS_ROLE_ARN matching the role you created. If this output is empty, the SA annotation was missing when the pod was created (webhook only fires at creation time, not on updates). Delete and recreate the pod after fixing the annotation.

Prove the pod is actually assuming the role

The cleanest proof is sts get-caller-identity, which returns the ARN of whoever is calling. If IRSA is working, the ARN will be an assumed-role ARN, not the EC2 node instance profile.

kubectl exec -n demo s3-test -- aws sts get-caller-identity

The output confirms the pod is using temporary credentials minted from the IRSA role, not the worker node’s EC2 role:

{
    "UserId": "AROAEXAMPLEROLEID12345:botocore-session-1712000000",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/eks-irsa-lab-demo-s3-reader-role/botocore-session-1712000000"
}

The assumed-role segment is what you want to see. If this shows arn:aws:sts::...:assumed-role/eksctl-irsa-lab-nodegroup..., the pod is falling back to IMDS and hitting the node role instead. That usually means the webhook did not fire or the SDK inside the container is too old to understand web identity.

List the bucket contents:

kubectl exec -n demo s3-test -- aws s3 ls s3://my-app-bucket/

And download the object to prove GetObject works end to end:

kubectl exec -n demo s3-test -- aws s3 cp s3://my-app-bucket/hello.txt -

The namespace-pinning negative test

This is the test most IRSA tutorials skip, and it is the one that proves the security model works. Create an identically-named ServiceAccount in a different namespace and try to assume the same role. The trust policy :sub condition should stop you cold.

kubectl create namespace demo2
kubectl create sa s3-reader -n demo2
kubectl annotate sa s3-reader -n demo2 eks.amazonaws.com/role-arn=arn:aws:iam::123456789012:role/eks-irsa-lab-demo-s3-reader-role

Launch a pod in the new namespace with the same SA name:

kubectl run s3-negative --image=amazon/aws-cli:2.22.4 -n demo2 --overrides='{"spec":{"serviceAccountName":"s3-reader"}}' --command -- sleep 3600

Try the same call that worked from the first pod:

kubectl exec -n demo2 s3-negative -- aws sts get-caller-identity

STS rejects the assume-role call because the sub claim in the token is system:serviceaccount:demo2:s3-reader, which does not match the trust policy’s pinned value:

An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity
command terminated with exit code 254

That is the security boundary doing its job. A compromised pod in one namespace cannot impersonate an IRSA ServiceAccount in another namespace just because the SA names happen to match.

The eksctl shortcut

Everything in this section collapses to a single eksctl command if you are in a hurry:

eksctl create iamserviceaccount \
  --cluster=irsa-lab \
  --region=eu-west-1 \
  --namespace=demo \
  --name=s3-reader \
  --role-name=eks-irsa-lab-demo-s3-reader-role \
  --attach-policy-arn=arn:aws:iam::123456789012:policy/irsa-lab-s3-read-policy \
  --approve

Under the hood, eksctl creates a CloudFormation stack that builds the trust policy, creates the IAM role, attaches the managed policy, and annotates the ServiceAccount. It is the right default for day-to-day work. But doing it manually once is worth the time because it teaches you exactly which piece fails when something goes wrong.

Production Use Case: AWS Load Balancer Controller with IRSA

The AWS Load Balancer Controller is the de facto ingress for EKS. It translates Kubernetes Ingress and Service objects into ALB and NLB resources in your AWS account, which means it needs permission to call dozens of EC2 and ELBv2 APIs. IRSA is the only sane way to grant it those permissions.

Download the canonical IAM policy from the project’s GitHub. This policy is maintained by AWS and is kept in sync with the controller release cycle.

curl -o iam-policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/main/docs/install/iam_policy.json

Create the policy in IAM:

aws iam create-policy --policy-name AWSLoadBalancerControllerIAMPolicy --policy-document file://iam-policy.json

Use eksctl to create the IAM role and ServiceAccount in one command. The SA must live in kube-system and be named aws-load-balancer-controller because the Helm chart looks for that exact combination by default:

eksctl create iamserviceaccount \
  --cluster=irsa-lab \
  --region=eu-west-1 \
  --namespace=kube-system \
  --name=aws-load-balancer-controller \
  --role-name=eks-irsa-lab-alb-controller-role \
  --attach-policy-arn=arn:aws:iam::123456789012:policy/AWSLoadBalancerControllerIAMPolicy \
  --approve

Look at the trust policy eksctl generated in the background. It pins :sub to system:serviceaccount:kube-system:aws-load-balancer-controller, sets the audience to sts.amazonaws.com, and references the correct OIDC provider ARN. No manual errors, no typos. This is why eksctl is the right default once you understand the underlying mechanics.

Install the controller via Helm, pointing it at the existing ServiceAccount:

Look up the VPC ID of your cluster (the Helm chart needs it along with the region):

aws eks describe-cluster --name irsa-lab --region eu-west-1 --query 'cluster.resourcesVpcConfig.vpcId' --output text

Export the returned VPC ID so you can reference it in the Helm install:

export VPC_ID=vpc-0eff978855e0677a0

Add the AWS EKS Helm repository and install the controller. Note the serviceAccount.create=false flag, which tells Helm to reuse the SA eksctl already created with the IRSA annotation instead of creating a new unannotated one:

helm repo add eks https://aws.github.io/eks-charts
helm repo update eks
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
  --namespace kube-system \
  --set clusterName=irsa-lab \
  --set region=eu-west-1 \
  --set vpcId=$VPC_ID \
  --set serviceAccount.create=false \
  --set serviceAccount.name=aws-load-balancer-controller \
  --wait

Check that both controller replicas come up Running:

kubectl get pods -n kube-system -l app.kubernetes.io/name=aws-load-balancer-controller

Expected output on a healthy install:

NAME                                            READY   STATUS    RESTARTS   AGE
aws-load-balancer-controller-7499459764-mkttk   1/1     Running   0          34s
aws-load-balancer-controller-7499459764-s9dgx   1/1     Running   0          34s

Tail the controller logs and look for the leader election and event source registration messages. If IRSA is misconfigured, you will see repeated WebIdentityErr or AccessDenied lines instead:

kubectl -n kube-system logs -l app.kubernetes.io/name=aws-load-balancer-controller --tail=20

A healthy log stream looks like this (truncated for readability):

{"level":"info","msg":"Successfully acquired lease","lock":"kube-system/aws-load-balancer-controller-leader"}
{"level":"info","msg":"Starting EventSource","controller":"ingress","source":"kind source: *v1.Ingress"}
{"level":"info","msg":"Starting Controller","controller":"ingress"}
{"level":"info","msg":"Starting workers","controller":"ingress","worker count":3}
{"level":"info","msg":"Serving webhook server","host":"","port":9443}

If any pod is in CrashLoopBackOff and the logs mention AccessDenied, you missed a permission in the IAM policy. Always pull the latest iam_policy.json from the controller version you are installing. Older policies lack permissions for newer controller features (WAFv2 integration, IPv6 ALBs, and similar).

Prove IRSA works end-to-end: deploy an Ingress and watch an ALB appear

The controller being Running is necessary but not sufficient. The real test is whether it can actually call ELBv2 APIs to provision a load balancer. Deploy a small nginx app with a Service and an Ingress so the controller has something to act on:

vim nginx-ingress.yaml

Paste the manifest. The ingressClassName: alb and the two alb.ingress.kubernetes.io/* annotations are what tell the AWS Load Balancer Controller (rather than some other ingress controller) to handle this Ingress:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
      - name: nginx
        image: nginx:1.27
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo
  namespace: demo
spec:
  type: NodePort
  selector:
    app: nginx-demo
  ports:
  - port: 80
    targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-demo
  namespace: demo
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  ingressClassName: alb
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nginx-demo
            port:
              number: 80

Apply the manifest:

kubectl apply -f nginx-ingress.yaml

Watch the Ingress until the ADDRESS column populates with a real ALB DNS name. This takes 30 to 90 seconds because the controller has to create the load balancer, attach the target group, and register the pod IPs:

kubectl -n demo get ingress nginx-demo -w

Once it is ready you will see something like this:

NAME         CLASS   HOSTS   ADDRESS                                                              PORTS   AGE
nginx-demo   alb     *       k8s-demo-nginxdem-4dbde51261-1244077269.eu-west-1.elb.amazonaws.com   80      90s

That DNS name is a real Application Load Balancer the controller just provisioned in your AWS account, using nothing but the temporary credentials it assumed through its IRSA role. Give the target group another 30 seconds to pass its first health checks, then hit the ALB with curl:

curl -s -o /dev/null -w "HTTP %{http_code}\n" http://k8s-demo-nginxdem-4dbde51261-1244077269.eu-west-1.elb.amazonaws.com/

A successful response confirms the whole chain: ingress reconciled, ALB created, target group registered, pods healthy, traffic flowing:

HTTP 200

Fetch the HTML to confirm nginx is actually answering rather than the ALB returning some cached error page:

curl -s http://k8s-demo-nginxdem-4dbde51261-1244077269.eu-west-1.elb.amazonaws.com/ | head -5

The nginx welcome page comes back through the ALB:

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>

At no point did we give the controller a long-lived access key. The pod’s ServiceAccount token was traded for temporary STS credentials, which the controller used to create real AWS resources on our behalf. That is IRSA doing exactly what it was designed to do.

Production Use Case: External Secrets Operator with Secrets Manager

External Secrets Operator (ESO) pulls secrets from AWS Secrets Manager (or SSM Parameter Store, or Vault, or many others) and materializes them as native Kubernetes Secrets. This is how you keep database passwords out of Git without running your own secret injection sidecar.

Create a secret in Secrets Manager first so there is something to pull:

aws secretsmanager create-secret --name prod/app/db --region eu-west-1 --secret-string '{"username":"appuser","password":"redacted-example-pass"}'

Save the ESO permissions policy to a file:

vim eso-policy.json

Paste in the policy. Restrict the resource to the exact secret path this cluster should read. Avoid Resource: "*" in production.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:prod/app/*"
    }
  ]
}

Create the IAM policy and the IRSA-enabled ServiceAccount in one go:

aws iam create-policy --policy-name irsa-lab-eso-policy --policy-document file://eso-policy.json

eksctl create iamserviceaccount \
  --cluster=irsa-lab \
  --region=eu-west-1 \
  --namespace=external-secrets \
  --name=external-secrets \
  --role-name=eks-irsa-lab-eso-role \
  --attach-policy-arn=arn:aws:iam::123456789012:policy/irsa-lab-eso-policy \
  --approve

Install ESO via Helm, reusing the ServiceAccount eksctl just created:

helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets \
  --set serviceAccount.create=false \
  --set serviceAccount.name=external-secrets

Now define a ClusterSecretStore that tells ESO where to fetch secrets from. Save the manifest:

vim aws-secrets-store.yaml

Paste the manifest below:

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: aws-secretsmanager
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets

Apply it:

kubectl apply -f aws-secrets-store.yaml

Check the status to confirm ESO can authenticate to AWS using the IRSA role:

kubectl get clustersecretstore aws-secretsmanager -o yaml | grep -A 6 conditions

A validated store reports Ready: True with the message store validated:

  conditions:
  - lastTransitionTime: "2026-04-10T09:35:27Z"
    message: store validated
    reason: Valid
    status: "True"
    type: Ready

Create an ExternalSecret that pulls the DB credentials and rematerializes them as a Kubernetes Secret:

vim app-db-externalsecret.yaml

Paste the manifest:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: app-db-credentials
  namespace: demo
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-secretsmanager
  target:
    name: app-db-credentials
    creationPolicy: Owner
  data:
  - secretKey: username
    remoteRef:
      key: prod/app/db
      property: username
  - secretKey: password
    remoteRef:
      key: prod/app/db
      property: password

Apply and verify:

kubectl apply -f app-db-externalsecret.yaml
kubectl get externalsecret -n demo app-db-credentials -o yaml | grep -A 6 conditions

A successful sync looks like this:

  conditions:
  - lastTransitionTime: "2026-04-10T09:35:51Z"
    message: secret synced
    reason: SecretSynced
    status: "True"
    type: Ready

The native Kubernetes Secret app-db-credentials now exists in the demo namespace and your applications can mount it like any other secret. Rotate the value in AWS Secrets Manager and ESO picks up the change on the next refresh interval, no pod restart required beyond the application’s own secret-reload behavior.

Production Use Case: Karpenter with IRSA

Karpenter is the modern node autoscaler for EKS. It replaces the old Cluster Autoscaler with a much faster, more flexible model that provisions nodes directly via EC2 Fleet rather than going through ASGs. In 2026, Karpenter is the default choice for new clusters, and it needs IAM permissions to call ec2:RunInstances, ec2:CreateTags, iam:PassRole, and a handful of EC2 describe calls.

Grab the canonical policy from the Karpenter docs (the policy differs by Karpenter version so always check karpenter.sh for the version you are installing). Save it to karpenter-policy.json and create it:

aws iam create-policy --policy-name KarpenterControllerPolicy --policy-document file://karpenter-policy.json

Create the IRSA ServiceAccount for the Karpenter controller:

eksctl create iamserviceaccount \
  --cluster=irsa-lab \
  --region=eu-west-1 \
  --namespace=karpenter \
  --name=karpenter \
  --role-name=eks-irsa-lab-karpenter-role \
  --attach-policy-arn=arn:aws:iam::123456789012:policy/KarpenterControllerPolicy \
  --approve

Install Karpenter with Helm, pointing it at the existing ServiceAccount:

helm install karpenter oci://public.ecr.aws/karpenter/karpenter \
  -n karpenter \
  --set serviceAccount.create=false \
  --set serviceAccount.name=karpenter \
  --set settings.clusterName=irsa-lab

Karpenter will start scheduling nodes against its NodePool and NodeClass resources as soon as pending pods appear. The full Karpenter configuration (NodePools, NodeClasses, topology spreads, disruption budgets) is a separate topic. What matters for this guide is that the controller’s IAM access comes exclusively from the IRSA role, not from a baked-in access key.

There is a second IAM role in the Karpenter picture that often confuses people: the node role. Karpenter itself uses the IRSA role we just created to call EC2 APIs. But the nodes Karpenter launches also need their own instance profile so kubelet can talk to the cluster, pull ECR images, and run the CNI. That node role is a completely separate IAM role, referenced by ARN inside the Karpenter EC2NodeClass, and it is not an IRSA role at all. Mixing these two up is a common first-time mistake when setting up Karpenter.

Cross-Account IRSA with External ID

Real organizations run multiple AWS accounts. A pod in the platform account might need to read from an S3 bucket in the data-lake account. IRSA handles this beautifully through standard STS role chaining, but you need to add an External ID to the trust chain to block confused deputy attacks.

The confused deputy problem works like this. Account B trusts Account A’s role. A malicious Account C convinces Account A (via a ticket, an integration, whatever) to execute a workload on their behalf. Account A’s IRSA role assumes Account B’s role and accesses data that Account C should never have seen. External ID is a shared secret that Account B demands on every assume-role call, and only legitimate Account A workloads know it. Without matching External ID, the assume-role fails.

Set up Account B’s role

In Account B (the data-lake account), create a role that trusts Account A’s IRSA role ARN and requires an External ID. Save the trust policy:

vim accountb-trust-policy.json

Paste in the policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/eks-irsa-lab-demo-s3-reader-role"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "irsa-lab-accountb-shared-id"
        }
      }
    }
  ]
}

Create the role and attach an S3 read policy to it. The target bucket lives in Account B:

aws iam create-role --role-name accountb-data-reader --assume-role-policy-document file://accountb-trust-policy.json
aws iam attach-role-policy --role-name accountb-data-reader --policy-arn arn:aws:iam::210987654321:policy/accountb-s3-read-policy

Let Account A’s IRSA role call AssumeRole on Account B

The IRSA role in Account A needs permission to call sts:AssumeRole targeting Account B’s role. Attach this policy to the existing eks-irsa-lab-demo-s3-reader-role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": "arn:aws:iam::210987654321:role/accountb-data-reader"
    }
  ]
}

Make the assume-role call from the pod

From inside the IRSA-enabled pod, call sts assume-role with the External ID, capture the temporary credentials, and export them into the shell:

kubectl exec -n demo -it s3-test -- bash

Inside the container, assume the Account B role:

aws sts assume-role \
  --role-arn arn:aws:iam::210987654321:role/accountb-data-reader \
  --role-session-name irsa-cross-account \
  --external-id irsa-lab-accountb-shared-id

STS returns a Credentials block with AccessKeyId, SecretAccessKey, and SessionToken. Export those into the shell and list the cross-account bucket:

export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
aws s3 ls s3://accountb-data-bucket/

You are now reading from a bucket your pod has no direct trust relationship with, using nothing but a JWT and an External ID. The whole chain has zero long-lived credentials anywhere on disk. STS returned a 1-hour session by default, and when it expires the SDK can repeat the assume-role dance transparently because the underlying web identity token (the primary IRSA credential) is still valid for another 18 hours.

In real applications you rarely write the manual assume-role dance. Modern AWS SDKs support a source_profile / role_arn pattern in ~/.aws/config that automates it, and for Python workloads the botocore built-in credential provider chain picks up web identity tokens plus chained role assumption transparently. Showing the manual flow is useful because it makes the mechanics visible.

Auditing IRSA Across Your Cluster

Most IRSA guides stop at “here’s how to set it up.” Once you have five or six IRSA-enabled workloads, the harder question is: what does my cluster look like right now? Which ServiceAccounts are wired to which IAM roles? This is the kind of question a security auditor will ask, and it is the one you want answered in thirty seconds, not thirty minutes.

Here is a one-liner that walks every ServiceAccount in the cluster and prints a table of IRSA mappings. The trick is reading the eks.amazonaws.com/role-arn annotation out of the JSON dump:

kubectl get sa -A -o json | python3 -c "
import sys,json
d = json.load(sys.stdin)
print(f'{\"Namespace\":<25} {\"ServiceAccount\":<40} {\"IAM Role\":<60}')
print('-' * 125)
for item in d.get('items', []):
    ann = item.get('metadata', {}).get('annotations', {})
    if 'eks.amazonaws.com/role-arn' in ann:
        ns = item['metadata']['namespace']
        name = item['metadata']['name']
        role = ann['eks.amazonaws.com/role-arn'].split('/')[-1]
        print(f'{ns:<25} {name:<40} {role:<60}')
"

Running it against the lab cluster produces exactly the inventory you want:

Namespace                 ServiceAccount                           IAM Role
-----------------------------------------------------------------------------------------------------------------------------
demo                      s3-reader                                eks-irsa-lab-demo-s3-reader-role
external-secrets          external-secrets                         eks-irsa-lab-eso-role
kube-system               aws-load-balancer-controller             eks-irsa-lab-alb-controller-role
kube-system               aws-node                                 eksctl-irsa-lab-addon-vpc-cni-Role1-07fh0xtQf45i

The aws-node entry is worth explaining because it surprises people. When eksctl creates a cluster, it enables IRSA for the VPC CNI addon automatically, because the CNI needs to call EC2 APIs to attach ENIs and allocate IPs. That role is managed by the eksctl addon stack, not by you, and the naming convention (eksctl-<cluster>-addon-vpc-cni-Role1-...) reflects that. If you ever see the VPC CNI suddenly fail to allocate IPs after a cluster upgrade, the first thing to check is whether this role still has the AmazonEKS_CNI_Policy attached. I have seen multiple incidents where an automated IAM cleanup script swept it up as “unused.”

Extend the audit further by chaining into the AWS CLI. Pipe each role name into aws iam list-attached-role-policies to see what each IRSA role actually grants:

for role in $(kubectl get sa -A -o jsonpath='{range .items[?(@.metadata.annotations.eks\.amazonaws\.com/role-arn)]}{.metadata.annotations.eks\.amazonaws\.com/role-arn}{"\n"}{end}' | awk -F/ '{print $NF}'); do
  echo "=== $role ==="
  aws iam list-attached-role-policies --role-name "$role" --query 'AttachedPolicies[].PolicyName' --output text
done

That is the kind of report security teams actually need. One pass across the cluster, one pass across IAM, every mapping visible on a single screen. Save it as a cron job that dumps to a file daily and you have a poor-man’s compliance report for a total investment of ten minutes.

Troubleshooting

Five errors cover 95% of real-world IRSA failures. Each one has a distinctive signature and a quick fix.

Error: “An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity”

This is the most common IRSA error, and it always means the trust policy’s :sub condition does not match the pod’s actual ServiceAccount. Maybe the namespace is wrong, maybe the SA name has a typo, maybe the trust policy was copy-pasted from a different cluster and the OIDC issuer ID is stale. Confirm the pod’s exact SA identity:

kubectl get sa -n demo s3-reader -o yaml

Compare the namespace and name against the trust policy’s system:serviceaccount:<ns>:<sa> string. They must match exactly, character for character, case sensitive. This is the same error you saw in the negative test earlier, and the fix is always the same: correct the trust policy or correct the SA, but never both at once.

Error: “InvalidIdentityToken: No OpenIDConnect provider found in your account”

IAM has no OIDC provider registered for this cluster’s issuer URL. It usually hits brand new clusters that were created without --with-oidc. Fix it with one command:

eksctl utils associate-iam-oidc-provider --cluster=irsa-lab --region=eu-west-1 --approve

Then verify the provider is listed with aws iam list-open-id-connect-providers and retry the pod’s assume-role call.

Error: “InvalidIdentityToken: Incorrect token audience”

The JWT’s audience claim does not match what IAM expects. On standard EKS this is rare because the pod-identity-webhook always sets the audience to sts.amazonaws.com. It shows up when people self-manage the webhook, use a custom admission controller, or minted tokens manually with the wrong --audience flag. Check the pod’s token with a JWT decoder and compare the aud claim to the ClientIDList on the IAM OIDC provider. Both sides must agree on sts.amazonaws.com.

Error: “WebIdentityErr: failed to retrieve credentials”

Two distinct causes share this message. First, the token file was not mounted because the webhook did not fire when the pod started. This happens when the SA was annotated after the pod was created. Delete the pod and let its controller recreate it so the webhook gets a chance to inject the volume. Second, the AWS SDK inside the image is ancient and predates web identity support. SDK versions older than 2019 (boto3 < 1.9.166, AWS SDK for Go v1 < 1.23.13, Java SDK v1 < 1.11.704) do not understand AWS_WEB_IDENTITY_TOKEN_FILE. Upgrade the image.

Pod uses node IAM role instead of IRSA

The pod runs without errors, but aws sts get-caller-identity returns the EC2 node instance profile ARN instead of the expected IRSA role. Two root causes. First, the pod was created before the SA annotation existed, so the webhook never ran for this pod. Second, the pod is bypassing the SDK’s web identity logic entirely and hitting the IMDS endpoint at 169.254.169.254, which returns the node role.

The fix for both is the same, and it is one of the most important hardening steps in this guide: set the IMDS hop limit to 1 on your worker node EC2 instances. Pods run in a container network namespace, so their calls to IMDS traverse an extra network hop through the host. A hop limit of 1 means pods cannot reach IMDS at all and the SDK is forced to use IRSA instead. Set this on the launch template or via aws ec2 modify-instance-metadata-options.

Security Hardening Checklist for Production IRSA

Running IRSA in production is not just about making it work. It is about making sure it keeps working safely as your cluster grows. This checklist captures the hard-won lessons from real incident reviews.

  • One IAM role per ServiceAccount. Do not share roles across SAs even if the permission set is identical. Auditing and rotation become nightmares once roles are shared.
  • Naming convention: eks-<cluster>-<namespace>-<sa>-role. Predictable names make the audit script from the previous section readable and they make Terraform modules trivial.
  • Trust policy :sub pinned exactly. No wildcards, no StringLike, no regex. Explicit equality only.
  • Always include :aud condition. It costs nothing and blocks a subtle class of token-replay attacks.
  • External ID on every cross-account role. Confused deputy is not theoretical. Use a per-tenant ID, not a global shared secret.
  • Permission boundaries on every IRSA role. A permission boundary caps the maximum permissions the role can ever receive, even if a misconfigured CI pipeline attaches something reckless.
  • IMDS hop limit 1 on all EC2 worker nodes. This is critical. Without it, pods can bypass IRSA entirely by hitting the metadata service directly. Set this in your launch template and verify with aws ec2 describe-instances --query 'Reservations[].Instances[].MetadataOptions'.
  • Tag IAM roles with cluster, namespace, app, managed-by. When you inherit a cluster from someone who left the company, these tags are the only thing that tells you what each role is for.
  • Manage IRSA roles as code. Terraform, eksctl ClusterConfig, or Crossplane. Click-ops IRSA roles become orphaned the moment someone renames a namespace.
  • Audit periodically with the one-liner from the previous section. Weekly at minimum. Feed the output into your compliance pipeline.

Beyond that list, lean on the upstream guidance from the AWS EKS Best Practices Guide. It is maintained by the EKS team and updated as new features land. Every IRSA hardening pattern in this checklist has a deeper writeup there if you want the full rationale.

Complete IRSA Module in Terraform

Production IRSA lives in Terraform, not in ad-hoc eksctl commands. Here is a complete working module that reads the OIDC issuer directly from the cluster (no hardcoding), creates an IAM policy and role with a correctly scoped trust policy, attaches the policy, and creates the Kubernetes ServiceAccount with the IRSA annotation in one apply. Every file below was tested end-to-end against a live EKS 1.33 cluster.

Create a working directory and the main configuration file:

mkdir irsa-terraform && cd irsa-terraform
vim main.tf

Paste the following. Note how the aws_eks_cluster data source pulls the OIDC issuer URL straight from the cluster, which means you never need to copy-paste it by hand:

terraform {
  required_version = ">= 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.70"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.33"
    }
  }
}

provider "aws" {
  region = var.region
}

data "aws_caller_identity" "current" {}

data "aws_eks_cluster" "this" {
  name = var.cluster_name
}

data "aws_eks_cluster_auth" "this" {
  name = var.cluster_name
}

locals {
  oidc_issuer       = replace(data.aws_eks_cluster.this.identity[0].oidc[0].issuer, "https://", "")
  oidc_provider_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/${local.oidc_issuer}"
}

provider "kubernetes" {
  host                   = data.aws_eks_cluster.this.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
  token                  = data.aws_eks_cluster_auth.this.token
}

resource "aws_iam_policy" "s3_read" {
  name        = "${var.cluster_name}-${var.namespace}-${var.service_account}-s3-read"
  description = "Allows the ${var.service_account} ServiceAccount to read ${var.bucket_name}"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = ["s3:GetObject", "s3:ListBucket"]
      Resource = [
        "arn:aws:s3:::${var.bucket_name}",
        "arn:aws:s3:::${var.bucket_name}/*"
      ]
    }]
  })
}

resource "aws_iam_role" "irsa" {
  name = "eks-${var.cluster_name}-${var.namespace}-${var.service_account}-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = local.oidc_provider_arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "${local.oidc_issuer}:sub" = "system:serviceaccount:${var.namespace}:${var.service_account}"
          "${local.oidc_issuer}:aud" = "sts.amazonaws.com"
        }
      }
    }]
  })

  tags = {
    cluster    = var.cluster_name
    namespace  = var.namespace
    sa         = var.service_account
    managed-by = "terraform"
  }
}

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

resource "kubernetes_namespace" "this" {
  metadata {
    name = var.namespace
  }
}

resource "kubernetes_service_account" "irsa" {
  metadata {
    name      = var.service_account
    namespace = kubernetes_namespace.this.metadata[0].name
    annotations = {
      "eks.amazonaws.com/role-arn" = aws_iam_role.irsa.arn
    }
  }
}

Next, add the variables file so the module is reusable across clusters and ServiceAccounts:

vim variables.tf

Paste these five variable declarations:

variable "region" {
  description = "AWS region where the EKS cluster lives"
  type        = string
  default     = "eu-west-1"
}

variable "cluster_name" {
  description = "Name of the EKS cluster"
  type        = string
}

variable "namespace" {
  description = "Kubernetes namespace for the ServiceAccount"
  type        = string
}

variable "service_account" {
  description = "Name of the Kubernetes ServiceAccount"
  type        = string
}

variable "bucket_name" {
  description = "S3 bucket the role should be allowed to read"
  type        = string
}

Add outputs so the role ARN is easy to grab from CI or a downstream module:

vim outputs.tf

Paste the output definitions:

output "role_arn" {
  description = "ARN of the IAM role the ServiceAccount assumes"
  value       = aws_iam_role.irsa.arn
}

output "oidc_issuer" {
  description = "OIDC issuer URL extracted from the cluster"
  value       = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
}

output "service_account" {
  description = "Fully qualified ServiceAccount reference"
  value       = "system:serviceaccount:${var.namespace}:${var.service_account}"
}

Finally, create a terraform.tfvars file with values for your cluster. Change cluster_name, bucket_name, and the namespace/SA names to match your environment:

vim terraform.tfvars

Paste your cluster-specific values:

cluster_name    = "irsa-lab"
namespace       = "tf-demo"
service_account = "tf-s3-reader"
bucket_name     = "my-app-bucket"
region          = "eu-west-1"

Initialize the working directory so Terraform downloads the AWS and Kubernetes providers:

terraform init

Run a plan to see exactly what will be created. You should see five resources to add and three computed outputs:

terraform plan

The bottom of the plan output shows the summary and the outputs that will become available after apply:

Plan: 5 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + oidc_issuer     = "https://oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC"
  + role_arn        = (known after apply)
  + service_account = "system:serviceaccount:tf-demo:tf-s3-reader"

Apply the plan:

terraform apply -auto-approve

Terraform creates the policy, role, policy attachment, namespace, and ServiceAccount in the correct order based on the dependency graph. On a healthy apply you will see:

aws_iam_policy.s3_read: Creation complete after 2s
aws_iam_role.irsa: Creation complete after 2s
kubernetes_namespace.this: Creation complete after 2s [id=tf-demo]
aws_iam_role_policy_attachment.s3_read: Creation complete after 1s
kubernetes_service_account.irsa: Creation complete after 1s [id=tf-demo/tf-s3-reader]

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

Outputs:

oidc_issuer = "https://oidc.eks.eu-west-1.amazonaws.com/id/4656D0B75473F4BFF2CFD94B17FB9DEC"
role_arn = "arn:aws:iam::123456789012:role/eks-irsa-lab-tf-demo-tf-s3-reader-role"
service_account = "system:serviceaccount:tf-demo:tf-s3-reader"

Verify the ServiceAccount was created with the IRSA annotation pointing at the Terraform-managed role:

kubectl -n tf-demo get sa tf-s3-reader -o yaml

The annotation should match the role_arn output exactly:

  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eks-irsa-lab-tf-demo-tf-s3-reader-role

Launch a test pod using the Terraform-created ServiceAccount to prove the full loop works:

kubectl -n tf-demo run tf-s3-test \
  --image=amazon/aws-cli:2.22.4 \
  --serviceaccount=tf-s3-reader \
  --command -- sleep 3600

Check the assumed role from inside the pod. The ARN should match the role_arn output from Terraform:

kubectl -n tf-demo exec tf-s3-test -- aws sts get-caller-identity

The assumed role confirms the pod is using the Terraform-built IRSA role, not the node IAM role:

{
    "UserId": "AROAEXAMPLEROLEID67890:botocore-session-1712000000",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/eks-irsa-lab-tf-demo-tf-s3-reader-role/botocore-session-1712000000"
}

List the bucket to confirm the attached policy actually grants S3 access:

kubectl -n tf-demo exec tf-s3-test -- aws s3 ls s3://my-app-bucket/

The bucket contents come back, proving the whole chain works: Terraform created the IAM policy, the role with the correct trust policy, the policy attachment, the Kubernetes ServiceAccount, and the annotation. The pod then assumed the role through OIDC and used the temporary credentials to call S3. Everything is declarative and reproducible.

For anything beyond a handful of roles, reach for the community module terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks. It implements this pattern with presets for the AWS Load Balancer Controller, External Secrets, Karpenter, Cluster Autoscaler, and about fifteen other common workloads. You pass the OIDC provider ARN, the namespace, and the SA name, and the module emits the correct trust policy every time. When you want to destroy everything the module created, run terraform destroy in the same working directory and Terraform will tear down the role, policy, attachment, ServiceAccount, and namespace in reverse dependency order.

Frequently Asked Questions

Can I use IRSA on Fargate?

Yes, and IRSA is the only option for Fargate workloads. EKS Pod Identity relies on the eks-pod-identity-agent DaemonSet, which cannot run on Fargate because Fargate pods do not share a host with DaemonSets. If any of your workloads run on Fargate profiles, keep them on IRSA.

How often does the projected ServiceAccount token rotate?

The default TTL on EKS is 24 hours, and kubelet refreshes the token file at approximately 80% of that window (roughly every 19 hours). You can override the TTL per-SA or per-pod with the eks.amazonaws.com/token-expiration annotation (value in seconds). STS session credentials rotate separately with their own 1-hour default, configurable up to 12 hours via the role’s MaxSessionDuration setting. The AWS SDK handles both rotations transparently as long as the pod keeps running.

What is the per-account OIDC provider limit?

IAM permits 100 OIDC identity providers per AWS account. Large organizations with many clusters sometimes hit this wall, and it is one of the main reasons AWS built EKS Pod Identity. Pod Identity does not require a per-cluster OIDC provider, which means you can scale to thousands of clusters in a single account without touching the limit.

Can I reuse the same IAM role for multiple ServiceAccounts?

Technically yes, by writing a trust policy with multiple StringEquals values in the :sub condition, or by using StringLike with a glob. Practically no. Sharing roles across ServiceAccounts violates least privilege because the broadest grant applies to all workloads sharing the role. The naming convention eks-<cluster>-<ns>-<sa>-role plus Terraform modules makes one-role-per-SA manageable even at hundreds of workloads.

Does IRSA work with Windows containers?

Yes. IRSA works on EKS Windows nodegroups running Kubernetes 1.18 or later. The pod-identity-webhook injects the same environment variables into Windows pods, and the AWS SDK for .NET picks up the web identity credentials the same way boto3 and the Go SDK do. One caveat: older AWS Tools for PowerShell versions had buggy web identity handling, so keep PowerShell AWSPowerShell.NetCore up to date.

If you are rolling this pattern out across a fleet, the official AWS IRSA documentation is worth bookmarking alongside this guide. For broader Kubernetes operations, the kubectl cheat sheet covers the day-two commands you will use around IRSA debugging, and the Velero backup and restore guide handles the disaster-recovery story for the workloads that now depend on these roles. Clusters built from scratch with these patterns in mind are dramatically easier to audit than ones where IRSA was retrofitted in a hurry.

Related Articles

Openshift Run Local OpenShift (OKD) with CRC on RHEL 10 / Fedora Docker Install Docker & Compose on Rocky Linux 9 Containers Deploy Kubernetes Cluster on Oracle Linux 8 with Kubeadm Kubernetes Kubectl Cheat Sheet for Kubernetes Admins and CKA Exam Prep

Leave a Comment

Press ESC to close