A pod on EKS is just a process on an EC2 node, and sooner or later that process needs to talk to AWS — read a config object from S3, publish a metric to CloudWatch, pull a secret, write to DynamoDB. The question of how it gets the credentials to do that is the single most important security decision on the cluster, because the easy answers are all wrong: giving the node’s instance role the permission means every pod on that node inherits it, and baking a static access key into a Kubernetes Secret means a long-lived credential that never rotates, leaks in a kubectl describe, and outlives the pod that used it. The right answer is IRSA — IAM Roles for Service Accounts — and it is the pattern that essentially every EKS component you will ever install is built on. Learn it once, deeply, and the AWS Load Balancer Controller, External DNS, the EBS and EFS CSI drivers, Cluster Autoscaler, Karpenter, cert-manager, and your own workloads all stop being mysterious: they are all the same five links in a chain.
This lesson teaches that chain end to end and builds every link with Terraform. You already know core Terraform — HCL, providers, resources, variables, for_each, state, the plan/apply loop — and you have met IAM policies, roles, trust policies and the aws_iam_policy_document data source in the Terraform on AWS: IAM roles, policies & S3 buckets lesson, which is the prerequisite for the identity half of this one. Here we assume a running EKS cluster already exists — provisioned in the Terraform on AWS: EKS cluster provisioning — VPC, node groups & add-ons lesson — and we bolt onto it the identity mechanism that makes pods first-class AWS principals. We will also meet EKS Pod Identity, the newer, simpler alternative AWS shipped in late 2023, and be honest about when each wins.
What you’ll build
The scenario is the one every team hits the moment an app on EKS needs AWS: a pod in the default namespace, running under a ServiceAccount called s3-reader, must read objects from one specific S3 bucket — and only read, only that bucket, with credentials that are short-lived, automatically rotated, and impossible to exfiltrate as a reusable key. No access keys in a Secret. No AmazonS3ReadOnlyAccess slapped on the node role so that the whole node — including the metrics agent, the log shipper, and every other team’s pods — can read every bucket in the account. Just this pod, this permission, this bucket.
By the end you will have a small root module — versions.tf, providers.tf, variables.tf, main.tf, outputs.tf — that reads your existing cluster with data.aws_eks_cluster, creates an IAM OIDC provider for the cluster’s issuer (built from a data.tls_certificate thumbprint), an IAM role whose trust policy federates that provider and — via a StringEquals condition on the :sub claim — allows only system:serviceaccount:default:s3-reader to assume it, a least-privilege S3-read policy attached to that role, and a Kubernetes ServiceAccount annotated with eks.amazonaws.com/role-arn. Then you will init, plan, apply, launch a throwaway pod with the amazon/aws-cli image running under that ServiceAccount, watch aws sts get-caller-identity come back as the assumed IRSA role (not the node role), prove aws s3 ls works against the one bucket, and destroy cleanly.
Why Terraform for this rather than eksctl, a shell script, or clicking in the console? Because IRSA is four coupled resources across two APIs (IAM and Kubernetes) whose correctness depends on strings matching exactly — the issuer URL in the OIDC provider, the provider ARN in the trust policy, the system:serviceaccount:ns:sa in the :sub condition, the role ARN in the SA annotation. Get one character wrong and you get an opaque AccessDenied at runtime, not at apply. Terraform interpolates every one of those strings from real resource attributes so they cannot drift apart, plans the whole chain before it touches anything, and lets you stamp the same pattern out for the next twenty add-ons as a module. Here is the honest comparison for this task:
| Approach | Cross-API wiring | Plan preview | Drift detection | Reusable per add-on | Best for |
|---|---|---|---|---|---|
| Console + kubectl | Copy-paste strings by hand | No | None | No | One-off learning, inspection |
eksctl create iamserviceaccount |
Yes (opinionated) | No | None | Somewhat (flags/config) | Quick clusters, demos |
Shell + aws/kubectl |
Manual, brittle | No | None | No | Bootstrap glue only |
Terraform (aws + kubernetes) |
Interpolated, typed | terraform plan |
plan/refresh |
Yes — a module | Repeatable, reviewed IaC |
The chain you are wiring has five stages left to right — the cluster’s OIDC issuer, an IAM OIDC provider, an IAM role with a :sub-bound trust policy, an annotated ServiceAccount, and the pod that swaps a projected token for role credentials at STS. Keep this diagram open for the rest of the lesson:
Reading it left to right: the EKS cluster publishes an OIDC issuer (a discovery endpoint plus signing keys); Terraform registers that issuer as an IAM OIDC provider so STS will trust tokens it signs; an IAM role carries a trust policy that federates the provider and pins the exact ServiceAccount via the :sub condition; the ServiceAccount is annotated with the role ARN; and the pod — after the admission webhook injects a projected token — exchanges that token at STS for temporary role credentials. The six badges mark the load-bearing decisions: the OIDC provider (one per cluster), the trust condition that binds one exact SA, the ⚠️ wildcard-sub footgun, the “no static keys” property, the projected-token-via-STS exchange, and the Pod Identity alternative. Each is a section below.
The problem: how a pod gets AWS credentials
Before IRSA, there were exactly two ways to give a pod AWS permissions, and both are anti-patterns you must be able to argue against in a design review. Understanding why they are bad is what makes IRSA click.
Option A — the node instance role. Every EKS worker node is an EC2 instance with an instance profile and an IAM role (the “node role”), which needs a baseline of permissions just to join the cluster (AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly, the CNI policy). The lazy move is to attach application permissions — s3:GetObject, dynamodb:*, whatever — to that node role. It works instantly, which is exactly the trap. Now every pod scheduled on that node can reach those permissions by hitting the instance metadata service (IMDS) at 169.254.169.254 and reading the node role’s credentials. Your logging DaemonSet, another team’s batch job, and a compromised sidecar all share one over-broad identity. There is no per-pod scoping, no per-pod audit trail (CloudTrail sees the node role, not the pod), and the blast radius of any single container escape is “everything the busiest node can do.”
Option B — static access keys in a Secret. Create an IAM user, generate an access key, drop it into a Kubernetes Secret, and mount it as AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY. This scopes permissions per-app (each app gets its own user), but the cure is worse: you now own a long-lived static credential. It doesn’t rotate unless you build rotation. It sits base64-encoded (not encrypted) in etcd and prints in plain text to anyone with get secret RBAC. It survives the pod, the deployment, and often the employee who created it. Leaked keys are the number-one cause of AWS account compromise, and a key in a Git-committed manifest or a kubectl describe secret is a breach waiting to be indexed. This is precisely the class of credential that the Secrets in IaC: Vault dynamic credentials in pipelines lesson exists to eliminate.
IRSA is the answer to both. It gives each ServiceAccount its own IAM role — so scoping is per-workload, not per-node — and the credentials are short-lived STS tokens minted on demand from a projected, auto-rotating JWT, so there is no static secret to leak and nothing that outlives the pod. Here is the comparison to keep in your head; this is the table that ends the design-review argument:
| Dimension | Node instance role | Static keys in Secret | IRSA |
|---|---|---|---|
| Credential type | Node role (shared) | Long-lived access key | Short-lived STS token |
| Scoping granularity | Per node (all pods) | Per app (per user) | Per ServiceAccount / pod |
| Rotation | Auto (IMDS), but shared | Manual — you build it | Automatic, ~hourly |
| Secret to leak? | No key, but over-broad | Yes — the key | None |
| Survives the pod? | N/A (node-level) | Yes (key persists) | No — dies with the token |
| CloudTrail attribution | Node role for all pods | The IAM user | The role, per workload |
| Blast radius of escape | Everything the node can do | That app’s key everywhere | Just that role’s permissions |
| Revocation | Edit node role (affects all) | Delete/rotate key | Detach policy / delete role |
| Cross-account | Awkward | Key sharing (bad) | Native (OIDC federation) |
| Verdict | ❌ Over-permissioned | ❌ Long-lived secret | ✅ Least-privilege, keyless |
The mechanism that makes “short-lived, keyless, per-pod” possible is OpenID Connect federation, and that is what the next section unpacks.
How IRSA works, end to end
IRSA is OIDC federation applied to Kubernetes ServiceAccounts. STS already knows how to trade a signed OIDC token from a trusted identity provider for temporary role credentials — that is AssumeRoleWithWebIdentity, the same API behind “log in with Google.” IRSA makes the EKS cluster itself the OIDC identity provider: the cluster signs a token that says “I am the ServiceAccount s3-reader in namespace default,” STS validates that signature and the role’s trust conditions, and hands back credentials. Nothing static ever changes hands.
Walk the chain link by link. Every one of these is a thing you can inspect, and every IRSA bug lives in exactly one of them:
| # | Stage | Who does it | What happens | Key artifact |
|---|---|---|---|---|
| 1 | OIDC issuer | EKS control plane | Cluster publishes an OIDC discovery doc + JWKS signing keys at a stable HTTPS URL | identity[0].oidc[0].issuer |
| 2 | IAM OIDC provider | You (Terraform) | Register the issuer in IAM so STS will trust tokens it signs, for audience sts.amazonaws.com |
aws_iam_openid_connect_provider |
| 3 | Role + trust policy | You (Terraform) | An IAM role allows sts:AssumeRoleWithWebIdentity from that provider only when :sub = the exact SA and :aud = STS |
assume_role_policy |
| 4 | Permissions policy | You (Terraform) | Attach what the role may actually do (e.g. s3:GetObject on one bucket) |
aws_iam_role_policy_attachment |
| 5 | Annotated ServiceAccount | You (Terraform) | Stamp the role ARN onto the SA so pods using it get wired up | eks.amazonaws.com/role-arn |
| 6 | Pod admission | EKS webhook | The mutating webhook sees the SA annotation and rewrites the pod spec | amazon-eks-pod-identity-webhook |
| 7 | Token projection | kubelet | A short-lived JWT (aud sts.amazonaws.com, ~1h TTL, auto-rotated) is mounted into the pod |
projected token file |
| 8 | Env injection | EKS webhook | AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE are set in every container |
env vars |
| 9 | STS exchange | AWS SDK in the pod | SDK reads the token, calls AssumeRoleWithWebIdentity; STS checks signature (JWKS), aud vs client_id_list, and the trust :sub/:aud |
STS request |
| 10 | Temp credentials | STS | Short-lived AccessKeyId/SecretAccessKey/SessionToken for the role are returned and cached |
assumed-role creds |
A few of the terms carry the whole design; pin them down because the trust-policy conditions are written in exactly this vocabulary:
| Term | What it is | In IRSA it equals |
|---|---|---|
Issuer (iss) |
The OIDC provider’s identity URL | https://oidc.eks.<region>.amazonaws.com/id/<cluster-id> |
Subject (sub) |
Who the token represents | system:serviceaccount:<namespace>:<serviceaccount> |
Audience (aud) |
Who the token is meant for | sts.amazonaws.com |
| JWKS | The issuer’s public signing keys | Served at <issuer>/keys; STS fetches it to verify signatures |
| Thumbprint | SHA-1 of the issuer’s TLS CA cert | Registered on the OIDC provider (see the thumbprint note below) |
| Projected token | A kubelet-minted, expiring JWT for the SA | Mounted at /var/run/secrets/eks.amazonaws.com/serviceaccount/token |
The crucial insight is step 9. The AWS SDK’s default credential provider chain checks the web-identity token before it ever falls back to the node’s IMDS instance profile. So a pod with an IRSA-annotated SA uses its own role, and a pod without one falls through to the node role. That ordering is why IRSA “just works” without any app code change — any reasonably recent SDK does the right thing automatically:
| Order | Provider | Source | Used by |
|---|---|---|---|
| 1 | Static env creds | AWS_ACCESS_KEY_ID/SECRET |
Explicit keys (avoid) |
| 2 | Web identity | AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN |
IRSA |
| 3 | Shared config/SSO | ~/.aws/config, SSO cache |
Laptops, CI |
| 4 | Container creds | AWS_CONTAINER_CREDENTIALS_FULL_URI |
EKS Pod Identity, ECS |
| 5 | EC2 IMDS | 169.254.169.254 |
Node instance role (last resort) |
The exact ordering varies slightly between SDKs, but the teaching point is invariant: web-identity (IRSA) and container-credentials (Pod Identity) are both consulted before IMDS, so a pod’s own identity always wins over the node role.
Now build each link in Terraform.
Terraform: the IAM OIDC provider
The cluster already has an OIDC issuer — EKS creates it for every cluster. You can see it in the API and in Terraform as identity[0].oidc[0].issuer. What does not exist yet is an IAM OIDC provider: the object in your account that tells IAM/STS “trust tokens signed by this issuer.” You create exactly one per cluster, and every IRSA role in that cluster references it.
First, read the cluster (assuming it lives in another stack; if the cluster is in the same config, reference aws_eks_cluster.this directly instead of the data source):
data "aws_eks_cluster" "this" {
name = var.cluster_name
}
locals {
oidc_issuer = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
# Strip the scheme; the trust-policy condition keys are prefixed with host+path, no https://
oidc_host = replace(local.oidc_issuer, "https://", "")
}
The IAM OIDC provider needs a thumbprint — the SHA-1 fingerprint of the CA certificate that fronts the issuer’s HTTPS endpoint. Never hardcode it; fetch it dynamically with the tls provider so it can never rot:
data "tls_certificate" "eks" {
url = local.oidc_issuer
}
resource "aws_iam_openid_connect_provider" "eks" {
url = local.oidc_issuer
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
tags = { Name = "${var.cluster_name}-irsa" }
}
Its three arguments are all load-bearing:
| Argument | Required | Value | Notes |
|---|---|---|---|
url |
Yes | The cluster’s issuer URL | Must match identity[0].oidc[0].issuer exactly (scheme, no trailing slash) |
client_id_list |
Yes | ["sts.amazonaws.com"] |
The audience STS expects; this is the aud the projected token carries |
thumbprint_list |
Yes | [sha1_fingerprint] |
CA cert fingerprint from data.tls_certificate; see the note below |
The thumbprint note (an exam favourite). Historically the thumbprint was security-critical: STS used it to verify the issuer’s TLS chain. Since 2023, for EKS-managed OIDC endpoints (which AWS hosts and fronts with a trusted public CA), STS no longer relies on the thumbprint at all — it validates the endpoint against Amazon’s own trust store. But the
aws_iam_openid_connect_providerresource still requires thethumbprint_listargument, so you must supply something. Computing it fromdata.tls_certificategives a correct value with zero maintenance; hardcoding the old9e99a48a99...root-CA string is the classic footgun that breaks when the chain changes. Thecertificateslist is the presented chain (leaf → root);[0]is what the canonical AWS example uses and it works because AWS ignores the value for managed OIDC.
Reusing an existing provider. If your cluster module (for example the community terraform-aws-eks module with enable_irsa = true) already created the OIDC provider, creating a second one fails with EntityAlreadyExists. In that case don’t create it — read it, or take the ARN the cluster module outputs:
# Option 1: the cluster module already outputs it
# provider_arn = module.eks.oidc_provider_arn
# Option 2: look it up by URL
data "aws_iam_openid_connect_provider" "eks" {
url = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
}
Whichever way you get it, the provider ARN — arn:aws:iam::<account>:oidc-provider/oidc.eks.<region>.amazonaws.com/id/<cluster-id> — is the value the role’s trust policy federates. That is the next link.
Terraform: the IRSA role and its trust policy
This is the heart of IRSA and the place every subtle bug lives. An IAM role has two policies that people constantly conflate: the trust policy (assume_role_policy) says who may assume the role, and the permissions policies (attached separately) say what the role may do once assumed. Keep them straight and most IRSA confusion evaporates:
Trust policy (assume_role_policy) |
Permissions policies (attached) | |
|---|---|---|
| Answers | Who may assume the role | What the role may do once assumed |
| Where it lives | Inline on the role | Separate managed/inline policies |
| For IRSA it contains | sts:AssumeRoleWithWebIdentity, Federated principal, :sub/:aud conditions |
e.g. s3:GetObject, s3:ListBucket on ARNs |
Has a Resource? |
No — principals + conditions only | Yes — the ARNs it acts on |
| Get it wrong → | Not authorized ... sts:AssumeRoleWithWebIdentity |
AccessDenied on the action (assume already worked) |
For IRSA, the trust policy must say three things precisely: the principal is the OIDC provider (federated), the action is sts:AssumeRoleWithWebIdentity, and a condition binds the exact ServiceAccount via :sub and the STS audience via :aud.
Build it with aws_iam_policy_document so every ARN and condition key is interpolated, not hand-typed:
data "aws_iam_policy_document" "irsa_assume" {
statement {
sid = "AllowOidcAssumeRole"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.eks.arn]
}
# Bind EXACTLY one ServiceAccount — StringEquals, never StringLike/wildcard
condition {
test = "StringEquals"
variable = "${local.oidc_host}:sub"
values = ["system:serviceaccount:${var.namespace}:${var.service_account}"]
}
# Pin the audience so a token minted for anything else is rejected
condition {
test = "StringEquals"
variable = "${local.oidc_host}:aud"
values = ["sts.amazonaws.com"]
}
}
}
resource "aws_iam_role" "irsa" {
name = "${var.cluster_name}-${var.service_account}-irsa"
assume_role_policy = data.aws_iam_policy_document.irsa_assume.json
}
Read the trust policy as STS reads it — this is the condition table to memorise, because these two lines are what stop any other pod from stealing this role:
| Condition key | Operator | Value | What it enforces |
|---|---|---|---|
<oidc-host>:aud |
StringEquals |
sts.amazonaws.com |
Token was minted for STS, not some other relying party |
<oidc-host>:sub |
StringEquals |
system:serviceaccount:<ns>:<sa> |
Exactly one SA in one namespace may assume the role |
<oidc-host>:sub |
StringLike |
system:serviceaccount:team-a:* |
⚠️ Any SA in team-a may assume it — a wildcard footgun |
<oidc-host>:sub |
StringEquals |
["...:sa-a", "...:sa-b"] |
Bind several exact SAs safely (a JSON list, not a wildcard) |
Notice the condition variable is not a fixed key like aws:PrincipalTag — it is <issuer-host-and-path>:sub, e.g. oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539...:sub. That is why we built local.oidc_host by stripping https://. Get that prefix wrong and STS silently never matches the condition, and every assume fails with Not authorized to perform sts:AssumeRoleWithWebIdentity.
When the SDK calls AssumeRoleWithWebIdentity, STS runs three checks in order — and the trust policy you just wrote is checks 2 and 3. Knowing which check produced which error is how you triage in seconds:
| # | STS validates | Against | Fails with |
|---|---|---|---|
| 1 | The token signature | The issuer’s JWKS at <issuer>/keys (fetched via the OIDC provider) |
InvalidIdentityToken |
| 2 | The token aud claim |
The provider’s client_id_list and the trust :aud condition |
Incorrect token audience |
| 3 | The token sub claim |
The trust policy’s :sub condition |
Not authorized to perform sts:AssumeRoleWithWebIdentity |
⚠️ The wildcard-sub footgun. The most dangerous mistake in all of IRSA is writing the
:subcondition withStringLikeand a wildcard —system:serviceaccount:*:*(any SA in the cluster) or evensystem:serviceaccount:default:*(any SA in a namespace). It looks convenient and it is a privilege-escalation hole: any pod that can run under any matching ServiceAccount can now assume a role that might grant far more than that pod should have. A malicious or compromised workload just needs to create a Deployment with the right SA name. Always useStringEqualswith the fully-qualifiedsystem:serviceaccount:<ns>:<sa>. When one role legitimately serves multiple ServiceAccounts (common for shared add-ons), list every exact subject in aStringEqualsarray — never collapse them to a*. Security scanners (CheckovCKV_AWS_..., and custom OPA/Conftest rules) flag wildcard IRSA trust conditions for exactly this reason.
With the trust settled, attach what the role may actually do. For the demo that is read-only access to one bucket — note the two S3 actions split across the bucket ARN (ListBucket) and the object ARN (GetObject), the least-privilege pattern from the IAM lesson:
data "aws_iam_policy_document" "s3_read" {
statement {
sid = "ListTheBucket"
effect = "Allow"
actions = ["s3:ListBucket"]
resources = ["arn:aws:s3:::${var.bucket_name}"]
}
statement {
sid = "ReadObjects"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::${var.bucket_name}/*"]
}
}
resource "aws_iam_policy" "s3_read" {
name = "${var.cluster_name}-${var.service_account}-s3-read"
policy = data.aws_iam_policy_document.s3_read.json
}
resource "aws_iam_role_policy_attachment" "s3_read" {
role = aws_iam_role.irsa.name
policy_arn = aws_iam_policy.s3_read.arn
}
The role is now complete: it trusts one exact ServiceAccount via the OIDC provider, and it grants exactly one read on one bucket. What is still missing is the Kubernetes side — the ServiceAccount that carries the role ARN.
Terraform: the annotated ServiceAccount
The link between the Kubernetes world and the IAM world is a single annotation on the ServiceAccount: eks.amazonaws.com/role-arn: <role ARN>. When a pod runs under that SA, the EKS pod-identity mutating webhook (a component EKS runs on the control plane, amazon-eks-pod-identity-webhook) sees the annotation and rewrites the pod so the SDK can find the role. You manage the SA with the kubernetes provider.
The kubernetes provider needs to authenticate to the cluster’s API server. The robust, credential-free way is the exec plugin calling aws eks get-token, so the provider borrows your AWS identity dynamically rather than storing a kubeconfig token that expires:
provider "kubernetes" {
host = data.aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", var.cluster_name]
}
}
| Provider argument | Source | Why |
|---|---|---|
host |
data.aws_eks_cluster.this.endpoint |
The API server URL |
cluster_ca_certificate |
base64decode(...certificate_authority[0].data) |
Trust the API server’s TLS |
exec (aws eks get-token) |
The aws CLI on the runner |
Short-lived token, no static kubeconfig secret in state |
Now the ServiceAccount. Use the _v1 resource (the current, GA variant) and set the annotation from the role ARN so the two can never disagree:
resource "kubernetes_service_account_v1" "app" {
metadata {
name = var.service_account # "s3-reader"
namespace = var.namespace # "default"
annotations = {
"eks.amazonaws.com/role-arn" = aws_iam_role.irsa.arn
}
}
}
That is the whole binding. When a pod mounts this SA, the webhook injects the following — you never write any of it, but you will debug it, so know exactly what “wired up” looks like:
| What the webhook injects | Value | Purpose |
|---|---|---|
AWS_ROLE_ARN (env) |
The role ARN from the annotation | Tells the SDK which role to assume |
AWS_WEB_IDENTITY_TOKEN_FILE (env) |
/var/run/secrets/eks.amazonaws.com/serviceaccount/token |
Path to the projected JWT |
AWS_STS_REGIONAL_ENDPOINTS (env) |
regional |
Use the in-region STS endpoint (faster, resilient) |
| Projected volume | A serviceAccountToken projection, aud: sts.amazonaws.com, ~1h expiry |
The auto-rotated token STS validates |
Annotation vs the SDK version. The webhook fires on pod admission, so a pod that was already running when you added the annotation does not get patched — you must recreate the pod (roll the Deployment). Also, extremely old SDKs (pre-2019) don’t understand
AWS_WEB_IDENTITY_TOKEN_FILE; every current SDK does. And you can tune the token TTL and the projected path with extra annotations (eks.amazonaws.com/token-expiration) when a workload holds credentials for long-running jobs.
You can, of course, manage the ServiceAccount with a raw manifest or Helm instead — the annotation is identical whichever tool sets it. Pick by who owns the SA:
| Method | How the annotation is set | When to use it |
|---|---|---|
kubernetes_service_account_v1 |
metadata.annotations in Terraform |
SA and role managed together in Terraform (this demo) |
kubernetes_manifest / raw YAML |
metadata.annotations in the manifest |
GitOps-owned ServiceAccounts (Argo CD/Flux apply the YAML) |
| Helm chart values | serviceAccount.annotations |
Add-ons installed by Helm — you pass the role ARN as a value |
eksctl create iamserviceaccount |
eksctl creates the role and the annotated SA | Non-Terraform clusters, quick demos |
The reusable module: iam-role-for-service-accounts-eks
You just wrote four resources and a data source to grant one pod one permission. For the tenth add-on you will not want to hand-write the trust policy again — and you especially do not want a junior engineer hand-writing the :sub condition and reaching for a wildcard. The community terraform-aws-modules/iam collection ships a submodule that does exactly this chain, correctly, with the trust conditions built for you:
module "irsa_s3_reader" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "~> 5.44"
role_name = "s3-reader-irsa"
# Attach your own policy (or use the module's built-in toggles below)
role_policy_arns = {
s3 = aws_iam_policy.s3_read.arn
}
oidc_providers = {
main = {
provider_arn = aws_iam_openid_connect_provider.eks.arn
namespace_service_accounts = ["default:s3-reader"] # ns:sa — exact, no wildcard
}
}
}
The module’s inputs map one-to-one to the concepts you now understand:
| Module input | What it sets | Notes |
|---|---|---|
role_name / role_name_prefix |
The IAM role name | Or let it generate one |
oidc_providers |
provider_arn + namespace_service_accounts |
Builds the StringEquals :sub condition per exact ns:sa — no wildcard |
role_policy_arns |
Map of policies to attach | Your custom least-privilege policies |
attach_*_policy toggles |
Curated AWS-managed policies for common add-ons | attach_ebs_csi_policy, attach_load_balancer_controller_policy, attach_external_dns_policy, attach_cluster_autoscaler_policy, … |
assume_role_condition_test |
The condition operator | Defaults to StringEquals — the safe default |
allow_self_assume_role |
Permit the role to assume itself | For SDKs that re-assume |
The reason this module is worth reaching for is the second row: instead of you finding and pasting the sprawling IAM policy the AWS Load Balancer Controller needs, you set attach_load_balancer_controller_policy = true and the module attaches AWS’s maintained policy. Wiring that controller end to end — its IRSA role, the Helm release, and the Ingress/Service objects it reconciles into ALBs and NLBs — is exactly the pattern the Terraform on AWS EKS: the AWS Load Balancer Controller lesson builds on top of everything here. Those built-in toggles are the ninety-percent case for add-ons:
| Add-on | Module toggle | What it grants |
|---|---|---|
| AWS Load Balancer Controller | attach_load_balancer_controller_policy |
Manage ALB/NLB, target groups, listeners |
| EBS CSI driver | attach_ebs_csi_policy |
Create/attach/delete EBS volumes |
| EFS CSI driver | attach_efs_csi_policy |
EFS access points |
| External DNS | attach_external_dns_policy |
Route 53 record changes |
| Cluster Autoscaler | attach_cluster_autoscaler_policy |
Describe/scale ASGs |
| Karpenter | (dedicated karpenter submodule) |
EC2 fleet, pricing, instance profile |
| cert-manager | attach_cert_manager_policy |
Route 53 DNS-01 challenges |
Use the module for anything shared or add-on-shaped; roll your own (as in the demo) when you want a small, obvious, auditable policy for one bespoke workload and don’t want a module dependency. Either way the trust chain is identical — the module just refuses to let you fat-finger it.
EKS Pod Identity: the newer alternative
In November 2023 AWS shipped EKS Pod Identity, a second way to give pods IAM roles that removes IRSA’s two biggest operational annoyances: the OIDC-provider-per-cluster and the role-that’s-welded-to-one-cluster’s-issuer. It is worth knowing well, because for greenfield clusters it is often the better default — and because interviewers now ask about both.
Pod Identity replaces the OIDC-federation trust with a plain service-principal trust and moves the “which SA maps to which role” binding out of the role’s trust policy and into a first-class association resource managed by the EKS API. There is no OIDC provider to create, and the role’s trust policy is generic enough to reuse across many clusters. It has three moving parts:
| Component | Terraform | Role |
|---|---|---|
| Pod Identity Agent add-on | aws_eks_addon (eks-pod-identity-agent) |
A DaemonSet that vends creds to pods via a local endpoint (169.254.170.23) |
| Role with a Pod-Identity trust | aws_iam_role (principal pods.eks.amazonaws.com) |
What the pod may do; trust is generic, not cluster-specific |
| Association | aws_eks_pod_identity_association |
Binds cluster + namespace + SA → role ARN |
The trust policy is dramatically simpler than IRSA’s — a service principal with two actions (sts:AssumeRole and sts:TagSession, because Pod Identity attaches session tags for the cluster, namespace, and SA):
# 1) Install the agent add-on (once per cluster)
resource "aws_eks_addon" "pod_identity" {
cluster_name = var.cluster_name
addon_name = "eks-pod-identity-agent"
}
# 2) A role trusting the EKS Pod Identity service principal — note: no OIDC, no :sub condition here
data "aws_iam_policy_document" "pod_identity_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole", "sts:TagSession"]
principals {
type = "Service"
identifiers = ["pods.eks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "pod_identity" {
name = "s3-reader-pod-identity"
assume_role_policy = data.aws_iam_policy_document.pod_identity_trust.json
}
resource "aws_iam_role_policy_attachment" "pi_s3" {
role = aws_iam_role.pod_identity.name
policy_arn = aws_iam_policy.s3_read.arn
}
# 3) Bind cluster + namespace + SA → role. This is the mapping (replaces the annotation)
resource "aws_eks_pod_identity_association" "s3_reader" {
cluster_name = var.cluster_name
namespace = var.namespace
service_account = var.service_account
role_arn = aws_iam_role.pod_identity.arn
depends_on = [aws_eks_addon.pod_identity]
}
Two things to notice. The SA needs no role-arn annotation — the association is the binding, done through the EKS API, so a plain ServiceAccount is enough. And the role’s trust policy has no cluster-specific :sub condition, which is what lets one role be reused across many clusters: the cluster/namespace/SA scoping lives in the association, not the role. Here is the head-to-head — the table interviewers want and the one that should drive your default:
| Dimension | IRSA | EKS Pod Identity |
|---|---|---|
| Trust mechanism | OIDC federation (AssumeRoleWithWebIdentity) |
Service principal pods.eks.amazonaws.com (AssumeRole) |
| OIDC provider per cluster | Required (1 per cluster; ~100/account cap) | None |
| SA → role binding | Annotation on the SA | aws_eks_pod_identity_association (EKS API) |
| Role reusable across clusters | No — trust pins one issuer + :sub |
Yes — trust is generic |
| Cluster prerequisite | Nothing extra (built-in webhook) | Install the eks-pod-identity-agent add-on |
| Session tags for ABAC | Not automatic | Yes (cluster/ns/sa/pod as tags) |
| Cross-account | Native via OIDC provider | Supported (later addition) |
| Fargate support | Yes | No (as of GA) |
| Scale ceiling | OIDC-provider & trust-policy sprawl | Simpler at many-clusters scale |
| Ecosystem maturity | Universal — every add-on documents it | Newer; growing add-on support |
| Best for | Existing clusters, Fargate, cross-account, add-ons that only document IRSA | New clusters, many clusters, ABAC via session tags |
The honest guidance: for a brand-new cluster where you control the add-ons, prefer Pod Identity — no OIDC provider to manage, reusable roles, ABAC-ready session tags. Stay on IRSA when you run Fargate (Pod Identity doesn’t support it), when an add-on’s docs/Helm chart only wire up IRSA, or when you already have a fleet standardised on it. Many teams run both during a migration, and that is fine — they are independent mechanisms. The rest of this lesson’s hands-on uses IRSA because it is what you will meet in 90% of existing clusters and every add-on tutorial; swapping to Pod Identity is the three resources above.
Hands-on: build it with Terraform
Time to build the whole IRSA chain and prove it from inside a pod. ⚠️ This assumes a running EKS cluster and provisions real IAM resources (IAM itself is free; the throwaway test pod runs for seconds on your existing nodes). You need: an EKS cluster you can reach (kubectl get nodes works), the aws CLI authenticated as a principal that can create IAM roles and OIDC providers, and an S3 bucket to read (any bucket in the account — put one object in it).
Create a directory and these five files. Here is what each owns:
| File | Contents |
|---|---|
versions.tf |
Terraform + aws, tls, kubernetes provider pins |
providers.tf |
aws provider; kubernetes provider via aws eks get-token |
variables.tf |
Region, cluster name, namespace, SA name, bucket name |
main.tf |
Cluster data source, OIDC provider, IRSA role + trust + S3 policy, annotated SA |
outputs.tf |
Role ARN, OIDC provider ARN, SA name, the verify command |
versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
tls = { source = "hashicorp/tls", version = "~> 4.0" }
kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.30" }
}
# In production, state lives in S3 + DynamoDB lock (see the getting-started lesson):
# backend "s3" {
# bucket = "kloudvin-tfstate"
# key = "eks/irsa/terraform.tfstate"
# region = "ap-south-1"
# dynamodb_table = "kloudvin-tflock"
# encrypt = true
# }
}
providers.tf
provider "aws" {
region = var.region
default_tags {
tags = { Project = "kloudvin", ManagedBy = "terraform", Lesson = "eks-irsa" }
}
}
provider "kubernetes" {
host = data.aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", var.cluster_name]
}
}
variables.tf
variable "region" {
type = string
default = "ap-south-1"
}
variable "cluster_name" {
type = string
description = "Name of the existing EKS cluster."
}
variable "namespace" {
type = string
default = "default"
}
variable "service_account" {
type = string
default = "s3-reader"
}
variable "bucket_name" {
type = string
description = "An existing S3 bucket the pod may read (put one object in it)."
}
main.tf
# ── Read the existing cluster ───────────────────────────────────────────────
data "aws_eks_cluster" "this" {
name = var.cluster_name
}
locals {
oidc_issuer = data.aws_eks_cluster.this.identity[0].oidc[0].issuer
oidc_host = replace(local.oidc_issuer, "https://", "")
}
# ── 1) IAM OIDC provider (one per cluster) ──────────────────────────────────
data "tls_certificate" "eks" {
url = local.oidc_issuer
}
resource "aws_iam_openid_connect_provider" "eks" {
url = local.oidc_issuer
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
tags = { Name = "${var.cluster_name}-irsa" }
}
# ── 2) IRSA role: trust the provider, bind EXACTLY one ServiceAccount ────────
data "aws_iam_policy_document" "irsa_assume" {
statement {
sid = "AllowOidcAssumeRole"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.eks.arn]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:sub"
values = ["system:serviceaccount:${var.namespace}:${var.service_account}"]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:aud"
values = ["sts.amazonaws.com"]
}
}
}
resource "aws_iam_role" "irsa" {
name = "${var.cluster_name}-${var.service_account}-irsa"
assume_role_policy = data.aws_iam_policy_document.irsa_assume.json
}
# ── 3) Least-privilege S3 read, attached to the role ────────────────────────
data "aws_iam_policy_document" "s3_read" {
statement {
sid = "ListTheBucket"
effect = "Allow"
actions = ["s3:ListBucket"]
resources = ["arn:aws:s3:::${var.bucket_name}"]
}
statement {
sid = "ReadObjects"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::${var.bucket_name}/*"]
}
}
resource "aws_iam_policy" "s3_read" {
name = "${var.cluster_name}-${var.service_account}-s3-read"
policy = data.aws_iam_policy_document.s3_read.json
}
resource "aws_iam_role_policy_attachment" "s3_read" {
role = aws_iam_role.irsa.name
policy_arn = aws_iam_policy.s3_read.arn
}
# ── 4) The annotated ServiceAccount (the IAM↔K8s link) ──────────────────────
resource "kubernetes_service_account_v1" "app" {
metadata {
name = var.service_account
namespace = var.namespace
annotations = {
"eks.amazonaws.com/role-arn" = aws_iam_role.irsa.arn
}
}
}
outputs.tf
output "irsa_role_arn" {
value = aws_iam_role.irsa.arn
}
output "oidc_provider_arn" {
value = aws_iam_openid_connect_provider.eks.arn
}
output "service_account" {
value = "${var.namespace}/${var.service_account}"
}
output "verify_cmd" {
description = "Run a throwaway pod under the SA and print who it is."
value = "kubectl run irsa-test --rm -it --image=amazon/aws-cli --namespace=${var.namespace} --overrides='{\"spec\":{\"serviceAccountName\":\"${var.service_account}\"}}' -- sts get-caller-identity"
}
Step 1 — terraform init
terraform init
You should see the aws, tls, and kubernetes providers download and Terraform has been successfully initialized!
Step 2 — terraform plan
terraform plan -var="cluster_name=kloudvin-dev" -var="bucket_name=kloudvin-irsa-demo-123456789012"
Terraform reads the cluster, computes the issuer and thumbprint, and shows 5 resources to add — the OIDC provider, the role, the policy, the attachment, and the ServiceAccount:
Plan: 5 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ irsa_role_arn = (known after apply)
+ oidc_provider_arn = (known after apply)
+ service_account = "default/s3-reader"
+ verify_cmd = "kubectl run irsa-test --rm -it ..."
Those five resources are the whole chain — keep this mapping handy when you read the plan:
| Resource | Link in the IRSA chain |
|---|---|
aws_iam_openid_connect_provider.eks |
Registers the cluster’s OIDC issuer in IAM |
aws_iam_role.irsa |
The role + its :sub-bound trust policy |
aws_iam_policy.s3_read |
The least-privilege permission (S3 read) |
aws_iam_role_policy_attachment.s3_read |
Binds the policy to the role |
kubernetes_service_account_v1.app |
The annotated ServiceAccount (IAM↔K8s link) |
Read the planned aws_iam_role.irsa and confirm the trust policy shows sts:AssumeRoleWithWebIdentity, the Federated principal, and — critically — the StringEquals condition on ...:sub equal to system:serviceaccount:default:s3-reader. If that condition is StringLike or has a *, stop and fix it.
Step 3 — terraform apply
terraform apply -var="cluster_name=kloudvin-dev" -var="bucket_name=kloudvin-irsa-demo-123456789012" # review, then: yes
On success:
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Outputs:
irsa_role_arn = "arn:aws:iam::123456789012:role/kloudvin-dev-s3-reader-irsa"
oidc_provider_arn = "arn:aws:iam::123456789012:oidc-provider/oidc.eks.ap-south-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B71EXAMPLE"
service_account = "default/s3-reader"
verify_cmd = "kubectl run irsa-test --rm -it ..."
Step 4 — verify from inside a pod
First confirm the ServiceAccount carries the annotation:
kubectl get sa s3-reader -n default -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}{"\n"}'
# → arn:aws:iam::123456789012:role/kloudvin-dev-s3-reader-irsa
Now the proof — launch a throwaway pod under that ServiceAccount and ask AWS who it is. The amazon/aws-cli image’s entrypoint is aws, so -- sts get-caller-identity runs the command:
kubectl run irsa-test --rm -it \
--image=amazon/aws-cli \
--namespace=default \
--overrides='{"spec":{"serviceAccountName":"s3-reader"}}' \
-- sts get-caller-identity
{
"UserId": "AROA...:botocore-session-1720512000",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/kloudvin-dev-s3-reader-irsa/botocore-session-1720512000"
}
That assumed-role/kloudvin-dev-s3-reader-irsa ARN is the entire lesson in one line: the pod is authenticated as the IRSA role, not the node role, with credentials it obtained by exchanging a projected token at STS — no key anywhere. Prove the permission actually works, and prove least privilege at the same time (the list/read succeed, a write is denied):
kubectl run irsa-test --rm -it --image=amazon/aws-cli --namespace=default \
--overrides='{"spec":{"serviceAccountName":"s3-reader"}}' \
--command -- sh -c '
aws s3 ls s3://kloudvin-irsa-demo-123456789012/ &&
echo hi | aws s3 cp - s3://kloudvin-irsa-demo-123456789012/should-fail.txt'
# → lists the objects (GetObject/ListBucket allowed) ...
# → upload fails: An error occurred (AccessDenied) ... s3:PutObject ← least privilege holds
Finally, peek at what the webhook injected, so you recognise a correctly wired pod when you are debugging a broken one:
kubectl run irsa-test --rm -it --image=amazon/aws-cli --namespace=default \
--overrides='{"spec":{"serviceAccountName":"s3-reader"}}' \
--command -- env | grep AWS
# → AWS_ROLE_ARN=arn:aws:iam::123456789012:role/kloudvin-dev-s3-reader-irsa
# AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/serviceaccount/token
# AWS_STS_REGIONAL_ENDPOINTS=regional
# AWS_DEFAULT_REGION=ap-south-1
If those env vars are absent, the webhook did not fire — the SA annotation is missing, misspelled, or the pod was created under the wrong ServiceAccount. That single env | grep AWS is the fastest IRSA triage there is.
Step 5 — terraform destroy
terraform destroy -var="cluster_name=kloudvin-dev" -var="bucket_name=kloudvin-irsa-demo-123456789012" # review, then: yes
This removes the ServiceAccount, the role, the policy, the attachment, and the OIDC provider — five resources, all free, gone in seconds. ⚠️ Do not destroy the OIDC provider if other IRSA roles on the cluster still reference it — it is shared cluster-wide. If you built the provider here but other stacks depend on it, either move it to the cluster stack or protect it with lifecycle { prevent_destroy = true }. The throwaway test pods were created by kubectl run --rm and already cleaned themselves up.
Variables, outputs and making it reusable
The demo is parameterised by cluster, namespace, SA and bucket, which is enough to reuse it by -var. The real leverage comes from turning “one IRSA role” into a map of roles so a single config grants several ServiceAccounts their own least-privilege roles from one place. Drive the role, its policy attachments, and the SA with for_each over a map:
variable "irsa" {
description = "Map of ServiceAccount → its policy ARNs."
type = map(object({
namespace = string
policy_arns = list(string)
}))
default = {
s3-reader = { namespace = "default", policy_arns = [] } # fill with real ARNs
}
}
resource "aws_iam_role" "irsa" {
for_each = var.irsa
name = "${var.cluster_name}-${each.key}-irsa"
assume_role_policy = data.aws_iam_policy_document.assume[each.key].json
}
data "aws_iam_policy_document" "assume" {
for_each = var.irsa
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.eks.arn]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:sub"
values = ["system:serviceaccount:${each.value.namespace}:${each.key}"]
}
condition {
test = "StringEquals"
variable = "${local.oidc_host}:aud"
values = ["sts.amazonaws.com"]
}
}
}
resource "kubernetes_service_account_v1" "app" {
for_each = var.irsa
metadata {
name = each.key
namespace = each.value.namespace
annotations = { "eks.amazonaws.com/role-arn" = aws_iam_role.irsa[each.key].arn }
}
}
Note the safety property this preserves: because the :sub is built from each.value.namespace and each.key, every generated role is still bound to exactly one SA — the for_each scales the pattern without ever introducing a wildcard. Beyond a handful of roles, graduate to the terraform-aws-modules/iam//modules/iam-role-for-service-accounts-eks module shown earlier and pass a map to it; use the built-in attach_*_policy toggles for standard add-ons and role_policy_arns for your own. Roll-your-own wins when the policy is small, bespoke and you want it visible in the plan; the module wins the moment you are wiring the fifth add-on or want AWS’s maintained policies for the Load Balancer Controller, EBS CSI, or External DNS.
Common mistakes and troubleshooting
IRSA fails in a small, recognisable set of ways, and almost every one is a string mismatch somewhere in the chain. This is the table to keep open mid-incident:
| Symptom | Likely cause | Fix |
|---|---|---|
Pod uses the node role (sts get-caller-identity shows .../eks-node-...) |
SA not annotated, or pod not using that SA | Add eks.amazonaws.com/role-arn; set serviceAccountName on the pod |
AWS_ROLE_ARN / token env vars absent in the pod |
Webhook didn’t fire — annotation missing/typo, or pod pre-dated it | Fix the annotation; recreate the pod (webhook runs on admission only) |
Not authorized to perform sts:AssumeRoleWithWebIdentity |
Trust :sub doesn’t match the SA, or issuer-host prefix wrong |
Make :sub exactly system:serviceaccount:<ns>:<sa>; strip https:// from the host |
AssumeRoleWithWebIdentity ... Incorrect token audience |
:aud condition or client_id_list isn’t sts.amazonaws.com |
Set both to sts.amazonaws.com |
AccessDenied on the AWS action (assume succeeded) |
Role has the trust but not the permission | Attach the permissions policy (e.g. s3:GetObject + kms:Decrypt for SSE-KMS) |
| Assume works for any pod, not just yours | ⚠️ Wildcard :sub (StringLike ...:*) |
Change to StringEquals with the exact SA(s) |
EntityAlreadyExists creating the OIDC provider |
The cluster module already made one | Use data.aws_iam_openid_connect_provider, not a new resource |
No OpenIDConnect provider found |
Provider not created, or URL mismatch | Create it; the url must equal identity[0].oidc[0].issuer exactly |
InvalidIdentityToken / signature invalid |
Stale/rotated token, clock skew, or wrong issuer | Recreate the pod; ensure node time is synced; verify the issuer URL |
| Token file present but SDK ignores it | Very old AWS SDK without web-identity support | Upgrade the SDK (any current version supports IRSA) |
Kubernetes provider Unauthorized at apply |
aws eks get-token identity lacks RBAC |
Grant your principal cluster access (aws-auth / access entry) |
Three of these deserve prose because they burn the most hours. The node-role fall-through is the most confusing symptom: everything seems fine — the pod runs, the SDK works — but get-caller-identity shows the node role and you get permissions you didn’t grant (or denials you don’t expect). It means the credential chain never found a web-identity token and fell through to IMDS. The cause is always upstream: no annotation, a typo’d annotation, or the pod running under default when your SA is s3-reader. Confirm with env | grep AWS — if AWS_WEB_IDENTITY_TOKEN_FILE is missing, the webhook didn’t fire, full stop. The :sub prefix mismatch is the classic “I copied a tutorial” bug: the trust condition variable must be <issuer-host-and-path>:sub, not oidc.eks...:sub with the wrong region, and definitely not with https:// still attached — that is why we compute local.oidc_host = replace(issuer, "https://", "") instead of typing it. The wildcard trust is the one that passes every test and fails the audit: StringLike with system:serviceaccount:*:* makes the role assumable by anything, so it “works” in the demo and quietly grants half the cluster access to your permissions; scanners and reviewers exist to catch it, but the real fix is to never write it — bind exact subjects, list several if you must.
Cost, cleanup and production notes
IRSA itself is free — IAM roles, policies, and OIDC providers carry no charge, and STS AssumeRoleWithWebIdentity calls are free. The only costs are indirect and worth knowing:
| Resource | Charge | Rough cost | Notes |
|---|---|---|---|
| IAM role / policy / OIDC provider | Free | ₹0 | Never billed |
STS AssumeRoleWithWebIdentity |
Free | ₹0 | Called on SDK init + on token refresh |
| The permissions the role grants | Per that service | Varies | An IRSA role that reads S3 incurs S3 request costs |
| Pod Identity Agent add-on | Free (runs on your nodes) | ₹0 | Uses a little node CPU/memory |
| The test pod | Node seconds | ~₹0 | --rm cleans it up |
To clean up: terraform destroy removes all five resources in seconds. The one caution is the shared OIDC provider — if multiple stacks or teams reference the same provider, destroying it from one stack breaks every other IRSA role on the cluster (they all fail AssumeRoleWithWebIdentity with “no provider found”). Own the OIDC provider in the cluster stack, not in each app stack, and reference it by ARN elsewhere.
Five production notes to carry forward:
| Area | Do this | Why |
|---|---|---|
| OIDC provider ownership | Create it once in the cluster stack; consume the ARN downstream | Avoids EntityAlreadyExists and destroy footguns |
| Exact subjects only | StringEquals on system:serviceaccount:ns:sa; never * |
Stops any-pod privilege escalation; passes Checkov/OPA |
| Least privilege per SA | One role per ServiceAccount, scoped to its resources | Per-workload blast radius; clean CloudTrail attribution |
| State hygiene | Remote S3 backend + lock; encrypt = true |
State holds role ARNs and the SA→role map |
| Consider Pod Identity | New clusters: eks-pod-identity-agent + associations |
No OIDC provider to manage; reusable roles; ABAC session tags |
Cheat-sheet
The resources and data sources for IRSA, at a glance:
| Resource / data source | Purpose |
|---|---|
data.aws_eks_cluster |
Read the cluster: endpoint, certificate_authority, identity[0].oidc[0].issuer |
data.tls_certificate |
Fetch the issuer’s CA fingerprint for the thumbprint |
aws_iam_openid_connect_provider |
Register the cluster’s OIDC issuer in IAM (one per cluster) |
data.aws_iam_policy_document (assume) |
Build the trust policy: Federated principal + :sub/:aud conditions |
aws_iam_role (assume_role_policy) |
The IRSA role and its trust |
aws_iam_policy + aws_iam_role_policy_attachment |
The least-privilege permissions the role grants |
kubernetes_service_account_v1 |
The SA annotated with eks.amazonaws.com/role-arn |
aws_eks_addon (eks-pod-identity-agent) |
(Pod Identity) install the agent |
aws_eks_pod_identity_association |
(Pod Identity) bind cluster+ns+sa → role, no OIDC/annotation |
terraform-aws-modules/iam//modules/iam-role-for-service-accounts-eks |
Module that builds the whole chain + curated add-on policies |
The strings and commands you’ll live in:
| Item | Value / command |
|---|---|
| Trust action | sts:AssumeRoleWithWebIdentity |
| Trust principal | Federated = the OIDC provider ARN |
:sub condition |
StringEquals system:serviceaccount:<ns>:<sa> |
:aud condition |
StringEquals sts.amazonaws.com |
| SA annotation | eks.amazonaws.com/role-arn: <role ARN> |
| Injected env | AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE |
| Token path | /var/run/secrets/eks.amazonaws.com/serviceaccount/token |
| Verify identity | kubectl run x --rm -it --image=amazon/aws-cli --overrides=... -- sts get-caller-identity |
| Triage a pod | ... --command -- env | grep AWS |
| Read the issuer | aws eks describe-cluster --name <c> --query cluster.identity.oidc.issuer |
Interview and exam questions
1. What problem does IRSA solve, and why not just use the node role? Pods need AWS permissions. The node instance role is shared by every pod on the node (no per-pod scoping, no per-pod audit, huge blast radius), and static keys are long-lived secrets that leak and never rotate. IRSA gives each ServiceAccount its own IAM role with short-lived, auto-rotated STS credentials and no static secret.
2. Walk the IRSA trust chain end to end. The EKS cluster publishes an OIDC issuer; you register it as an IAM OIDC provider; an IAM role’s trust policy federates that provider and conditions on the :sub (the exact system:serviceaccount:ns:sa) and :aud (sts.amazonaws.com); the SA is annotated with the role ARN; the admission webhook injects AWS_ROLE_ARN + a projected token; the SDK calls sts:AssumeRoleWithWebIdentity; STS validates the token against the provider’s JWKS and the trust conditions and returns temporary role credentials.
3. What exactly is in the trust policy’s condition, and why? Two StringEquals conditions: <issuer-host>:sub = system:serviceaccount:<ns>:<sa> binds one exact ServiceAccount, and <issuer-host>:aud = sts.amazonaws.com ensures the token was minted for STS. The variable is prefixed with the issuer host+path (no https://), which is why you strip the scheme.
4. Why is a wildcard :sub dangerous? StringLike with system:serviceaccount:*:* (or ns:*) lets any matching ServiceAccount assume the role, so any pod that can run under such an SA gains the role’s permissions — a privilege-escalation hole. Always StringEquals an exact subject; list several exact subjects if one role serves multiple SAs.
5. Where does the pod’s token come from and what’s in it? The kubelet mints a projected ServiceAccount token (a signed JWT) with aud: sts.amazonaws.com, a ~1h expiry, and auto-rotation, mounted at /var/run/secrets/eks.amazonaws.com/serviceaccount/token. Its sub is system:serviceaccount:<ns>:<sa> — exactly what the trust policy checks.
6. Why does IRSA “just work” with no app code change? The AWS SDK’s default credential provider chain checks the web-identity token (AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN) before falling back to EC2 IMDS. The webhook sets those env vars, so the SDK assumes the IRSA role automatically; a pod without the annotation falls through to the node role.
7. What’s the role of data.tls_certificate and the thumbprint? aws_iam_openid_connect_provider requires a thumbprint_list (SHA-1 of the issuer’s CA cert). data.tls_certificate fetches it dynamically so you never hardcode a value that rots. For AWS-managed EKS OIDC endpoints, STS no longer relies on the thumbprint, but the argument is still required.
8. Compare IRSA and EKS Pod Identity. IRSA uses OIDC federation and needs one IAM OIDC provider per cluster, with the role’s trust pinned to that issuer and :sub. Pod Identity uses a generic pods.eks.amazonaws.com service-principal trust plus an aws_eks_pod_identity_association for the cluster/ns/sa binding — no OIDC provider, reusable roles across clusters, session tags for ABAC, but no Fargate support. Prefer Pod Identity for new clusters; keep IRSA for Fargate, cross-account, and add-ons that only document IRSA.
9. A pod gets AccessDenied even though get-caller-identity shows the right assumed role. What’s wrong? The trust worked (it assumed the role) but the role lacks the permission for the action — attach the permissions policy. For an SSE-KMS object you also need kms:Decrypt, the most-forgotten permission.
10. How do you grant one IRSA role to several ServiceAccounts safely? List each exact system:serviceaccount:ns:sa in a StringEquals array in the trust condition — never collapse to a wildcard. The iam-role-for-service-accounts-eks module’s namespace_service_accounts does this for you.
11. (Practical) terraform plan wants to create a second OIDC provider and errors with EntityAlreadyExists. Why and what do you do? The cluster (or its module) already created the provider — it’s one per cluster. Switch from resource "aws_iam_openid_connect_provider" to data.aws_iam_openid_connect_provider (or consume the cluster module’s oidc_provider_arn output) and reference that ARN in the trust policy.
12. Why annotate the ServiceAccount rather than the pod, and what happens to an already-running pod? The annotation lives on the SA so every pod using it inherits the wiring, and RBAC governs who can use the SA. The mutating webhook fires on pod admission, so a pod already running when you add the annotation is not patched — roll the Deployment to recreate its pods.
Key takeaways
- IRSA is the core EKS security pattern — every add-on (Load Balancer Controller, External DNS, EBS CSI, Karpenter) and your own workloads use it to get scoped AWS permissions without a static key or an over-broad node role.
- The chain is five links: cluster OIDC issuer → IAM OIDC provider (one per cluster) → IAM role whose trust federates the provider and pins
:sub/:aud→ annotated ServiceAccount → pod swaps a projected token for STS credentials. - The trust policy is the whole security boundary:
StringEqualson<issuer-host>:sub=system:serviceaccount:ns:saand:aud=sts.amazonaws.com. Interpolate every string with Terraform so they can’t drift. - Never use a wildcard
:sub.StringLike ...:*lets any pod assume the role — a privilege-escalation hole; bind exact subjects (list several if needed). - No static keys, ever: credentials are short-lived STS tokens the SDK obtains automatically because web-identity is checked before IMDS; a pod without the annotation quietly falls through to the node role.
- Reach for the
iam-role-for-service-accounts-eksmodule for add-ons — it builds the trust correctly and ships curated policies viaattach_*_policytoggles. - Know Pod Identity: the newer
pods.eks.amazonaws.com+aws_eks_pod_identity_associationmodel drops the per-cluster OIDC provider and makes roles reusable across clusters — prefer it on new clusters, but stay on IRSA for Fargate and cross-account. - Verify, don’t trust:
aws sts get-caller-identityfrom inside the pod must show the assumed IRSA role, andenv | grep AWSis the fastest triage when it shows the node role instead.