CKS — IAM Roles & Cloud Identity

9 minute read

IAM Roles & Cloud Identity

Kubernetes nodes and Pods inherit cloud IAM permissions. If a Pod is compromised on AWS, the attacker gets whichever IAM role is attached to the node — potentially full access to S3, EC2, or IAM itself.

This section covers how to apply least privilege at the cloud-identity layer and isolate permissions per workload using IRSA, Workload Identity, and OIDC federation.


1. The Node IAM Problem

By default, every Pod on a node shares the node’s IAM role. This is a massive blast radius.

Without per-Pod identity:

  ┌──────────────────────────────────────────┐
  │               EC2 Node                   │
  │  IAM Role: eks-worker-role               │
  │  Permissions: S3, DynamoDB, SQS, ECR     │
  │                                          │
  │  ┌──────────┐  ┌──────────┐              │
  │  │ App Pod  │  │ Debug Pod│              │
  │  │ needs S3 │  │ needs    │              │
  │  │ only     │  │ nothing  │              │
  │  └──────────┘  └──────────┘              │
  │                                          │
  │  Both Pods get S3 + DynamoDB + SQS + ECR │ ← Overprivileged
  └──────────────────────────────────────────┘
With per-Pod identity (IRSA / Workload Identity):

  ┌──────────────────────────────────────────┐
  │               EC2 Node                   │
  │  Node IAM Role: minimal (ECR pull only)  │
  │                                          │
  │  ┌──────────┐  ┌──────────┐              │
  │  │ App Pod  │  │ Debug Pod│              │
  │  │ SA: app  │  │ SA: debug│              │
  │  │ → S3 R/O │  │ → (none) │              │
  │  └──────────┘  └──────────┘              │
  │                                          │
  │  Each Pod gets only what it needs         │ ← Least privilege
  └──────────────────────────────────────────┘

Security Impact: A compromised Pod with node-level IAM can exfiltrate data from any service the node role can access. With per-Pod identity, the blast radius is limited to that Pod’s specific permissions.


2. Principle of Least Privilege for Node Roles

Minimal Node IAM Policy (AWS)

Worker nodes need only enough IAM to join the cluster and pull container images.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeRegions",
        "ecr:GetAuthorizationToken",
        "ecr:BatchGetImage",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchCheckLayerAvailability"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "eks:DescribeCluster"
      ],
      "Resource": "arn:aws:eks:*:*:cluster/*"
    }
  ]
}

Common Over-Privileged Permissions to Remove

Permission Risk Needed?
s3:* Full S3 access from any Pod No — use IRSA
iam:* Create roles, escalate privileges Never
ec2:RunInstances Spin up instances for cryptomining No
sts:AssumeRole (broad) Assume any role in the account No — scope to specific roles
secretsmanager:GetSecretValue Access all secrets No — use IRSA per app

Separate Control Plane and Worker Roles

Control Plane Role:                    Worker Node Role:
├── eks:* (cluster management)         ├── ecr:Get* (pull images)
├── elasticloadbalancing:*             ├── ec2:Describe* (node info)
├── iam:CreateServiceLinkedRole        └── eks:DescribeCluster
├── kms:Decrypt (envelope encryption)
└── logs:PutLogEvents

Never share the same role for both!

3. AWS IRSA (IAM Roles for Service Accounts)

IRSA maps a Kubernetes ServiceAccount to an IAM role. Only Pods using that ServiceAccount get the role’s permissions.

How IRSA Works

1. EKS cluster has an OIDC provider
2. IAM role trust policy trusts the OIDC provider + specific SA
3. Pod with that SA gets a projected token
4. AWS SDK exchanges the token for temporary IAM credentials
5. Pod can call AWS APIs with scoped permissions

Step-by-Step Setup

Step 1: Enable OIDC Provider

# Get OIDC issuer URL
aws eks describe-cluster --name my-cluster \
  --query "cluster.identity.oidc.issuer" --output text
# Output: https://oidc.eks.us-east-1.amazonaws.com/id/ABCDEF1234567890

# Create the OIDC provider
eksctl utils associate-iam-oidc-provider \
  --cluster my-cluster --approve

Step 2: Create IAM Role with Trust Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/ABCDEF1234567890"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/ABCDEF1234567890:sub": "system:serviceaccount:app-ns:app-sa",
          "oidc.eks.us-east-1.amazonaws.com/id/ABCDEF1234567890:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}

Key points in the trust policy:

  • :sub restricts to a specific namespace (app-ns) and ServiceAccount (app-sa)
  • :aud restricts to the expected audience

Step 3: Attach Permissions Policy

aws iam attach-role-policy \
  --role-name app-s3-reader \
  --policy-arn arn:aws:iam::111122223333:policy/S3ReadOnlyAppBucket
{
  "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/*"
      ]
    }
  ]
}

Step 4: Annotate ServiceAccount

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: app-ns
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/app-s3-reader

Step 5: Use the ServiceAccount in a Pod

apiVersion: v1
kind: Pod
metadata:
  name: app-pod
  namespace: app-ns
spec:
  serviceAccountName: app-sa
  containers:
  - name: app
    image: my-app:latest
    # The AWS SDK automatically picks up IRSA credentials
    # via the projected service account token

Step 6: Verify

# Exec into the Pod
kubectl exec -it app-pod -n app-ns -- sh

# Check the identity
aws sts get-caller-identity
# Should show the IRSA role ARN, not the node role

# Test access
aws s3 ls s3://my-app-bucket/    # Should work
aws s3 ls s3://other-bucket/     # Should fail — AccessDenied

4. GCP Workload Identity

GCP’s equivalent of IRSA. Maps a Kubernetes ServiceAccount to a GCP Service Account.

Setup

# Enable Workload Identity on the cluster
gcloud container clusters update my-cluster \
  --workload-pool=my-project.svc.id.goog

# Create a GCP Service Account
gcloud iam service-accounts create app-gsa \
  --display-name="App GSA"

# Grant permissions to the GSA
gcloud projects add-iam-policy-binding my-project \
  --member="serviceAccount:app-gsa@my-project.iam.gserviceaccount.com" \
  --role="roles/storage.objectViewer"

# Bind K8s SA to GCP SA
gcloud iam service-accounts add-iam-policy-binding \
  app-gsa@my-project.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:my-project.svc.id.goog[app-ns/app-ksa]"

Annotate the Kubernetes ServiceAccount

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-ksa
  namespace: app-ns
  annotations:
    iam.gke.io/gcp-service-account: app-gsa@my-project.iam.gserviceaccount.com

Use in a Pod

apiVersion: v1
kind: Pod
metadata:
  name: app-pod
  namespace: app-ns
spec:
  serviceAccountName: app-ksa
  nodeSelector:
    iam.gke.io/gke-metadata-server-enabled: "true"
  containers:
  - name: app
    image: gcr.io/my-project/my-app:latest

5. OIDC Federation for External Identity

OIDC federation lets you use external identity providers (GitHub Actions, GitLab CI, Azure AD) to assume IAM roles without static credentials.

Example: GitHub Actions Assuming AWS Role

# .github/workflows/deploy.yml
jobs:
  deploy:
    permissions:
      id-token: write   # Required for OIDC
      contents: read
    steps:
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v4
      with:
        role-to-assume: arn:aws:iam::111122223333:role/github-deploy
        aws-region: us-east-1
    - name: Deploy to EKS
      run: kubectl apply -f manifests/

Trust Policy for GitHub OIDC

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Security Impact: OIDC federation eliminates static credentials. No access keys to leak, no secrets to rotate. The trust boundary is cryptographic.


6. Auditing IAM Role Usage

AWS CloudTrail

# Search for actions by a specific role
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceType,AttributeValue=AWS::IAM::Role \
  --max-results 10

# Search for AssumeRoleWithWebIdentity events (IRSA usage)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
  --max-results 20

Detect Overprivileged Roles

# AWS IAM Access Analyzer — find unused permissions
aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:111122223333:analyzer/my-analyzer

# List unused services for a role (last 90 days)
aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::111122223333:role/eks-worker-role

Kubernetes-Side Audit

# Check which Pods use which ServiceAccounts
kubectl get pods -A -o custom-columns=\
'NAMESPACE:.metadata.namespace,POD:.metadata.name,SA:.spec.serviceAccountName'

# Check for IRSA annotations
kubectl get sa -A -o json | \
  jq '.items[] | select(.metadata.annotations["eks.amazonaws.com/role-arn"] != null) |
  {namespace: .metadata.namespace, name: .metadata.name,
   role: .metadata.annotations["eks.amazonaws.com/role-arn"]}'

7. Node IAM Role Risks

Instance Metadata Service (IMDS) Attack

If an attacker gets code execution in a Pod (e.g., SSRF vulnerability), they can reach the instance metadata service:

# From inside a compromised Pod (without IRSA):
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/eks-worker-role

This returns temporary credentials for the node’s IAM role.

Mitigations

Mitigation How
Use IRSA / Workload Identity Pods get scoped credentials, not node credentials
IMDSv2 (token required) Blocks SSRF because attacker can’t get the token header
Network Policy Block Pod access to 169.254.169.254
Minimal node role Even if leaked, permissions are limited

Block IMDS with Network Policy

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-metadata-access
  namespace: default
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 169.254.169.254/32

Enforce IMDSv2

# Require IMDSv2 (token-based) for all instances
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-endpoint enabled

Summary

Strategy Implementation Risk Mitigated
Minimal node IAM role ECR pull + EC2 describe only Limits blast radius of node compromise
IRSA / Workload Identity Per-SA cloud identity Pod isolation for cloud permissions
OIDC federation External IdP → temporary creds No static credentials
IMDSv2 enforcement --http-tokens required SSRF → IMDS attacks
Block IMDS via NetworkPolicy Deny egress to 169.254.169.254 Direct IMDS access from Pods
Audit with CloudTrail Monitor AssumeRoleWithWebIdentity Detect misuse

Practice Questions

Q1: A Pod on EKS can access any S3 bucket in the account, even though it only needs to read from one bucket. The Pod uses the default ServiceAccount. How do you fix this?
  1. Create an IAM policy scoped to the specific bucket:
{
  "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/*"
      ]
    }
  ]
}
  1. Create an IAM role with a trust policy that trusts the EKS OIDC provider for a specific ServiceAccount.

  2. Create a Kubernetes ServiceAccount annotated with the role ARN:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: app-ns
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/app-s3-reader
  1. Update the Pod to use the new ServiceAccount:
spec:
  serviceAccountName: app-sa
  1. Remove broad S3 permissions from the node IAM role.
Q2: An attacker exploits an SSRF vulnerability in a Pod and retrieves the node's IAM credentials via the instance metadata service. Name three mitigations.
  1. Enforce IMDSv2 — requires a token obtained via PUT request. SSRF attacks typically use GET and cannot set the required X-aws-ec2-metadata-token-ttl-seconds header.
aws ec2 modify-instance-metadata-options \
  --instance-id i-xxxx \
  --http-tokens required
  1. Block IMDS via NetworkPolicy — deny egress to 169.254.169.254/32:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-metadata
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except: [169.254.169.254/32]
  1. Use IRSA — Pods get their own scoped credentials via the projected service account token, making node-level credentials irrelevant.
Q3: Explain the difference between a node IAM role and IRSA. Which should an application Pod use?
Aspect Node IAM Role IRSA
Scope All Pods on the node Specific ServiceAccount
Granularity Node-level Pod-level
Credential source Instance metadata (IMDS) Projected SA token → STS
Blast radius All Pods share same permissions Only Pods with the SA get permissions
Rotation Automatic (instance profile) Automatic (short-lived tokens)

Application Pods should always use IRSA (or Workload Identity on GCP). The node IAM role should contain only the minimum permissions needed for kubelet and the container runtime.

Q4: You need to let a GitHub Actions workflow deploy to your EKS cluster without storing any AWS access keys. How?

Use OIDC federation:

  1. Create an IAM OIDC identity provider for token.actions.githubusercontent.com
  2. Create an IAM role with a trust policy that trusts the GitHub OIDC provider, scoped to your repository and branch:
{
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
    }
  }
}
  1. In the GitHub Actions workflow:
permissions:
  id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::111122223333:role/github-deploy
    aws-region: us-east-1

No static credentials are stored anywhere. GitHub mints a short-lived OIDC token, AWS STS validates it and returns temporary credentials.

Q5: A node's IAM role has iam:* permissions. Why is this dangerous, and what should you do?

Why it’s dangerous:

  • iam:* grants the ability to create new users, roles, and policies
  • An attacker can escalate privileges: create an admin user, attach AdministratorAccess policy
  • They can persist access: create access keys for a new user that survive even if the node is terminated

What to do:

  1. Immediately remove iam:* from the node role
  2. Replace with the minimum needed permissions:
{
  "Effect": "Allow",
  "Action": [
    "ecr:GetAuthorizationToken",
    "ecr:BatchGetImage",
    "ecr:GetDownloadUrlForLayer",
    "ec2:DescribeInstances",
    "eks:DescribeCluster"
  ],
  "Resource": "*"
}
  1. Audit CloudTrail for any IAM actions taken via the compromised role
  2. Check for any users/roles/policies that may have been created by the attacker
  3. Move application-specific permissions to IRSA with scoped policies

Previous Up Next
Minimize Host OS Footprint System Hardening Minimize External Access

Updated: