CKS — IAM Roles & Cloud Identity
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:
:subrestricts to a specific namespace (app-ns) and ServiceAccount (app-sa):audrestricts 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?
- 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/*"
]
}
]
}
-
Create an IAM role with a trust policy that trusts the EKS OIDC provider for a specific ServiceAccount.
-
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
- Update the Pod to use the new ServiceAccount:
spec:
serviceAccountName: app-sa
- 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.
- 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-secondsheader.
aws ec2 modify-instance-metadata-options \
--instance-id i-xxxx \
--http-tokens required
- 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]
- 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:
- Create an IAM OIDC identity provider for
token.actions.githubusercontent.com - 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"
}
}
}
- 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
AdministratorAccesspolicy - They can persist access: create access keys for a new user that survive even if the node is terminated
What to do:
- Immediately remove
iam:*from the node role - Replace with the minimum needed permissions:
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ec2:DescribeInstances",
"eks:DescribeCluster"
],
"Resource": "*"
}
- Audit CloudTrail for any IAM actions taken via the compromised role
- Check for any users/roles/policies that may have been created by the attacker
- Move application-specific permissions to IRSA with scoped policies
| Previous | Up | Next |
|---|---|---|
| Minimize Host OS Footprint | System Hardening | Minimize External Access |