Cloud Engineering

Setting Up IAM Roles for Service Accounts (IRSA) on EKS

Intermediate35 min to complete9 min readApril 22, 2026Updated August 19, 2026

Quick answer

Give individual Kubernetes pods scoped AWS permissions without node-level IAM roles. IRSA uses the EKS OIDC provider to issue short-lived credentials per ServiceAccount — no static keys, no overly permissive nodes.

intermediate · 35 min

Before you begin

  • An EKS cluster with an OIDC provider enabled
  • AWS CLI configured with IAM admin permissions
  • kubectl configured for the cluster
  • eksctl (optional but makes OIDC setup simpler)
AWS
EKS
IRSA
IAM
Kubernetes
Security

The naive approach to giving pods AWS access: attach IAM policies to the node group IAM role. This gives every pod on every node the same permissions. One compromised pod means access to everything.

IRSA (IAM Roles for Service Accounts) scopes permissions to a specific ServiceAccount in a specific namespace. A pod in production with ServiceAccount s3-writer can write to S3. The pod next to it with ServiceAccount my-api cannot.

How IRSA Works

  1. EKS has an OIDC provider — a URL that proves "this ServiceAccount token came from this cluster"
  2. You create an IAM role with a trust policy that says "allow this role to be assumed if the token is from this specific ServiceAccount in this namespace"
  3. Kubernetes mounts a projected token (not the default SA token) into the pod
  4. The AWS SDK exchanges that token for temporary credentials automatically

Your application code doesn't change — boto3, the AWS SDK for Go, or any other AWS SDK picks up the credentials from environment variables that the EKS admission controller injects.

Step 1: Verify the OIDC Provider Exists

bash
1# Get cluster OIDC issuer URL
2aws eks describe-cluster \
3  --name my-cluster \
4  --query "cluster.identity.oidc.issuer" \
5  --output text
6# https://oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E
7
8# Check if an IAM OIDC provider exists for this URL
9aws iam list-open-id-connect-providers

If no provider exists for your cluster's OIDC URL, create it:

bash
eksctl utils associate-iam-oidc-provider \
  --cluster my-cluster \
  --region ap-south-1 \
  --approve

Or manually. AWS requires the root CA thumbprint for OIDC providers — not the leaf certificate fingerprint that openssl x509 returns by default. For EKS OIDC endpoints, use the known root CA thumbprint:

bash
1OIDC_URL=$(aws eks describe-cluster \
2  --name my-cluster \
3  --query "cluster.identity.oidc.issuer" \
4  --output text | sed 's|https://||')
5
6# Root CA thumbprint for oidc.eks.*.amazonaws.com (Starfield Services Root CA G2)
7# This is stable and does not change with certificate rotations.
8THUMBPRINT="9e99a48a9960b14926bb7f3b02e22da2b0ab7280"
9
10aws iam create-open-id-connect-provider \
11  --url "https://${OIDC_URL}" \
12  --client-id-list sts.amazonaws.com \
13  --thumbprint-list $THUMBPRINT

Step 2: Create the IAM Policy

Define what the pod is allowed to do:

bash
1# Example: write-only access to a specific S3 bucket
2cat > s3-writer-policy.json <<EOF
3{
4  "Version": "2012-10-17",
5  "Statement": [
6    {
7      "Effect": "Allow",
8      "Action": [
9        "s3:PutObject",
10        "s3:PutObjectAcl"
11      ],
12      "Resource": "arn:aws:s3:::my-app-uploads/*"
13    },
14    {
15      "Effect": "Allow",
16      "Action": [
17        "s3:ListBucket"
18      ],
19      "Resource": "arn:aws:s3:::my-app-uploads"
20    }
21  ]
22}
23EOF
24
25aws iam create-policy \
26  --policy-name S3WriterPolicy \
27  --policy-document file://s3-writer-policy.json

Step 3: Create the IAM Role with a Trust Policy

The trust policy says: "allow this role to be assumed by the OIDC token if it's from ServiceAccount s3-writer in namespace production."

bash
1ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
2OIDC_URL=$(aws eks describe-cluster \
3  --name my-cluster \
4  --query "cluster.identity.oidc.issuer" \
5  --output text | sed 's|https://||')
6
7cat > trust-policy.json <<EOF
8{
9  "Version": "2012-10-17",
10  "Statement": [
11    {
12      "Effect": "Allow",
13      "Principal": {
14        "Federated": "arn:aws:iam::${ACCOUNT_ID}:oidc-provider/${OIDC_URL}"
15      },
16      "Action": "sts:AssumeRoleWithWebIdentity",
17      "Condition": {
18        "StringEquals": {
19          "${OIDC_URL}:sub": "system:serviceaccount:production:s3-writer",
20          "${OIDC_URL}:aud": "sts.amazonaws.com"
21        }
22      }
23    }
24  ]
25}
26EOF
27
28aws iam create-role \
29  --role-name S3WriterRole \
30  --assume-role-policy-document file://trust-policy.json
31
32aws iam attach-role-policy \
33  --role-name S3WriterRole \
34  --policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/S3WriterPolicy
35
36ROLE_ARN=$(aws iam get-role \
37  --role-name S3WriterRole \
38  --query "Role.Arn" --output text)
39echo "Role ARN: $ROLE_ARN"

The sub claim format is always system:serviceaccount:<namespace>:<service-account-name>. This is the key trust condition — it scopes the role to exactly one ServiceAccount in one namespace.

Step 4: Create the Kubernetes ServiceAccount

Annotate the ServiceAccount with the IAM role ARN:

bash
1kubectl create namespace production 2>/dev/null || true
2
3kubectl create serviceaccount s3-writer -n production
4
5kubectl annotate serviceaccount s3-writer \
6  -n production \
7  eks.amazonaws.com/role-arn=$ROLE_ARN

Or declaratively:

yaml
1apiVersion: v1
2kind: ServiceAccount
3metadata:
4  name: s3-writer
5  namespace: production
6  annotations:
7    eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/S3WriterRole"
8    eks.amazonaws.com/token-expiration: "86400"   # Token TTL in seconds (default: 86400)

Step 5: Use the ServiceAccount in a Pod

yaml
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4  name: my-app
5  namespace: production
6spec:
7  template:
8    spec:
9      serviceAccountName: s3-writer   # This is the key field
10      containers:
11        - name: app
12          image: my-app:latest
13          env:
14            - name: AWS_REGION
15              value: ap-south-1
16            - name: S3_BUCKET
17              value: my-app-uploads

When the pod starts, EKS automatically:

  1. Mounts a projected token at /var/run/secrets/eks.amazonaws.com/serviceaccount/token
  2. Sets AWS_WEB_IDENTITY_TOKEN_FILE env var pointing to the token
  3. Sets AWS_ROLE_ARN env var to the IAM role from the annotation

The AWS SDK reads these environment variables and handles credential exchange automatically.

Step 6: Verify It Works

Deploy a test pod and check its credentials:

bash
1kubectl run irsa-test \
2  --image=amazon/aws-cli:latest \
3  --serviceaccount=s3-writer \
4  --namespace=production \
5  --rm -it \
6  --restart=Never \
7  -- sts get-caller-identity

Expected output:

json
{
  "UserId": "AROAEXAMPLE:eks-production-s3-writ-xxxx",
  "Account": "123456789012",
  "Arn": "arn:aws:sts::123456789012:assumed-role/S3WriterRole/eks-production-s3-writ-xxxx"
}

The Arn confirms the pod assumed S3WriterRole. Test the actual permission:

bash
1kubectl run irsa-test \
2  --image=amazon/aws-cli:latest \
3  --serviceaccount=s3-writer \
4  --namespace=production \
5  --rm -it \
6  --restart=Never \
7  -- s3 cp /etc/hostname s3://my-app-uploads/test.txt

Step 7: Remove Node-Level IAM Permissions

After verifying IRSA works for all your services, remove overly permissive node-level IAM policies. Only these three are required on the node group role:

AmazonEKSWorkerNodePolicy      — worker node lifecycle
AmazonEKS_CNI_Policy           — VPC networking
AmazonEC2ContainerRegistryReadOnly — pulling images from ECR

Remove anything else (S3, DynamoDB, SQS, etc.) — those belong on per-service IRSA roles.

Common Patterns

Multiple services, different roles:

bash
# Each service gets its own ServiceAccount + IAM role
kubectl annotate sa my-api -n production eks.amazonaws.com/role-arn=$MY_API_ROLE_ARN
kubectl annotate sa worker -n production eks.amazonaws.com/role-arn=$WORKER_ROLE_ARN
kubectl annotate sa exporter -n production eks.amazonaws.com/role-arn=$EXPORTER_ROLE_ARN

Cross-account access: The trust policy can reference an OIDC provider in account A while the role lives in account B. Add the cross-account OIDC ARN to the federated principal.

Token expiration tuning: For long-running batch jobs, extend the token TTL:

yaml
eks.amazonaws.com/token-expiration: "43200"  # 12 hours

Debugging

NoCredentialProviders: The pod isn't using the annotated ServiceAccount. Check kubectl get pod my-pod -o yaml | grep serviceAccountName.

AssumeRoleWithWebIdentity: InvalidIdentityToken: The OIDC URL in the trust policy doesn't match the cluster's OIDC issuer. Double-check with aws eks describe-cluster --name my-cluster --query cluster.identity.oidc.issuer.

AccessDenied: The role is assumed correctly but doesn't have permission for the specific action. Verify with aws iam simulate-principal-policy.

Frequently Asked Questions

Why is my pod still using the node's IAM role?

The ServiceAccount annotation must be present before the pod starts, since the admission webhook injects the token and environment variables at creation time. Annotating the ServiceAccount afterwards does nothing to pods already running — restart them. Check the pod for the injected environment variables to confirm the mutation actually happened.

What is the difference between IRSA and EKS Pod Identity?

Both give a pod its own IAM role. IRSA federates through an OIDC provider and requires a trust policy referencing the cluster's issuer, so each cluster needs its own setup. Pod Identity uses an agent and an association, which removes the per-cluster trust policy and is simpler to manage across many clusters. IRSA is more widely documented; Pod Identity is the newer path.

Can two ServiceAccounts share one IAM role?

Technically yes, by listing both subjects in the trust policy, and it defeats the purpose. The value of IRSA is that a compromised workload has only its own permissions. Sharing a role restores the shared-credential problem you adopted IRSA to remove. Create a role per workload unless they genuinely need identical access.

How do I verify which identity a pod is actually using?

Call the STS get-caller-identity endpoint from inside the pod, which returns the assumed role ARN. This is definitive in a way that reading configuration is not — it tells you what the AWS API sees rather than what you intended. If it returns the node's role, the token was not injected.

Official References

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.