IAM roles for service accounts on EKS. A small primer.


One of the small things in EKS that quietly fixes a very old problem: how do my pods get AWS credentials without me stuffing access keys into Kubernetes secrets?

If you have only ever used IAM roles on EC2 instances, the story so far is: the instance has a role, the instance metadata service hands out temporary credentials, every AWS SDK looks them up automatically. Done. It works because there is exactly one piece of code per instance.

On a Kubernetes node, you have many pods. They have different jobs. The S3-backup pod shouldn’t be able to read the database. The pod that talks to Stripe doesn’t need any AWS access at all. If you hang IAM on the node, every pod on that node gets every permission.

IAM Roles for Service Accounts (IRSA) is the EKS feature that fixes this. A few moving parts; once you see them once they are obvious.

The pieces

  1. The cluster has an OIDC provider — when you create the cluster, EKS gives it a public JWKS endpoint.
  2. You register that OIDC provider as an identity provider in IAM.
  3. You create an IAM role with a trust policy that says “I trust tokens from this OIDC provider, but only if they claim to be service account X in namespace Y.”
  4. You annotate the Kubernetes service account with the role ARN.
  5. EKS’ admission webhook mounts a projected token into pods that use that service account, and sets the right environment variables so the AWS SDK picks it up.

The trust policy

The interesting one is step 3. The trust policy on the role looks like this:

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

The sub condition is the important one — it pins the role to one specific service account in one specific namespace. Without it, anything in the cluster could assume the role.

The service account

apiVersion: v1
kind: ServiceAccount
metadata:
  name: s3-backup
  namespace: payments
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::1111…:role/eks-payments-s3-backup

That annotation is all the Kubernetes side needs. The webhook handles the rest at pod-admission time:

The AWS SDK’s default credential chain knows to read that token and call sts:AssumeRoleWithWebIdentity, which gives the pod temporary credentials that rotate themselves.

A Terraform snippet

In practice I would never click any of this in the console. Here is a compact Terraform module sketch that wires the whole thing up. The aws_iam_openid_connect_provider resource is created once per cluster; the role can be created per service account.

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

data "tls_certificate" "oidc" {
  url = data.aws_eks_cluster.main.identity[0].oidc[0].issuer
}

resource "aws_iam_openid_connect_provider" "eks" {
  url             = data.aws_eks_cluster.main.identity[0].oidc[0].issuer
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.oidc.certificates[0].sha1_fingerprint]
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.eks.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub"
      values   = ["system:serviceaccount:${var.namespace}:${var.service_account}"]
    }
  }
}

resource "aws_iam_role" "sa" {
  name               = "eks-${var.namespace}-${var.service_account}"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

Attach whatever managed or inline policies the workload actually needs to that role. The service account in Kubernetes is annotated with aws_iam_role.sa.arn and the rest is plumbing.

The thing people forget

The AWS SDK on the pod also has to be recent enough to support the web-identity credential provider. Anything modern is fine. If you are using a very old SDK or an old version of the CLI that pre-dates IRSA, the SDK will fall back to the node IAM role, you will get inconsistent behavior, and you will lose half a day to it. I have lost the half day, you don’t have to.

Other than that — this is the single piece of EKS that I think every team should set up on day one and never look at again.