Introduction#
AWS IAM controls who (identity) can do what (permissions) on which resources. IAM is also one of the most frequent sources of security incidents — misconfigured policies granting excessive permissions or public S3 buckets are the canonical examples. This post covers IAM roles, policies, and trust relationships with a focus on least privilege.
Core IAM Concepts#
User: a person or service with long-term credentials (access key/secret key). Avoid users for service-to-service auth — use roles.
Role: an identity that can be assumed by users, services, or AWS services. Issues temporary credentials (STS tokens). No permanent credentials.
Policy: a JSON document defining allowed/denied actions on resources. Attached to users, groups, or roles.
Trust Policy: defines who is allowed to assume a role.
Writing IAM Policies#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3ReadAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
],
"Condition": {
"StringEquals": {
"s3:prefix": ["uploads/", "public/"]
}
}
},
{
"Sid": "DenyDeleteObject",
"Effect": "Deny",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}
Deny always overrides Allow. The evaluation logic: default deny → check explicit deny → check explicit allow.
IAM Roles for EC2 and ECS#
EC2 instances and ECS tasks use instance profiles to assume roles. Applications retrieve credentials from the instance metadata service — no hardcoded credentials.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Trust policy: allows EC2 instances to assume this role
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
1
2
3
4
5
6
# In application code: boto3 automatically uses instance profile credentials
import boto3
# No credentials needed — boto3 uses the instance role
s3 = boto3.client("s3", region_name="us-east-1")
response = s3.get_object(Bucket="my-bucket", Key="config.json")
EKS: IRSA (IAM Roles for Service Accounts)#
Kubernetes pods on EKS use IRSA to assume IAM roles per service account, rather than sharing node-level IAM roles.
1
2
3
4
5
6
7
# Create IRSA role
eksctl create iamserviceaccount \
--name s3-reader \
--namespace production \
--cluster my-cluster \
--attach-policy-arn arn:aws:iam::123456789:policy/S3ReadPolicy \
--approve
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Trust policy created by eksctl:
{
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789:oidc-provider/oidc.eks.us-east-1.amazonaws.com/..."
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.../sub": "system:serviceaccount:production:s3-reader"
}
}
}]
}
1
2
3
4
5
6
7
8
# Kubernetes service account with role annotation
apiVersion: v1
kind: ServiceAccount
metadata:
name: s3-reader
namespace: production
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/s3-reader-role
Cross-Account Access#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Role in Account B, trusted by Account A
// Trust policy on Account B's role:
{
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::ACCOUNT-A-ID:role/deployment-role"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "unique-external-id-12345"
}
}
}]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# In Account A: assume the role in Account B
sts = boto3.client("sts")
response = sts.assume_role(
RoleArn="arn:aws:iam::ACCOUNT-B-ID:role/cross-account-role",
RoleSessionName="deployment-session",
ExternalId="unique-external-id-12345",
)
credentials = response["Credentials"]
# Use the assumed role credentials
s3 = boto3.client(
"s3",
aws_access_key_id=credentials["AccessKeyId"],
aws_secret_access_key=credentials["SecretAccessKey"],
aws_session_token=credentials["SessionToken"],
)
IAM Policy Analyzer and Access Advisor#
1
2
3
4
5
6
7
8
9
# Find overly permissive policies
aws accessanalyzer list-findings --analyzer-name my-analyzer
# Check which permissions are actually used (access advisor)
aws iam generate-service-last-accessed-details \
--arn arn:aws:iam::123456789:role/my-role
# Get the report
aws iam get-service-last-accessed-details --job-id <job-id>
Use access advisor to identify unused permissions and remove them.
Conclusion#
Use roles instead of long-term access keys for all service-to-service communication. Use IRSA for EKS workloads to provide pod-level IAM granularity. Apply least privilege by starting with no permissions and adding only what is needed. Run IAM Access Analyzer regularly to find overly permissive policies and unused permissions.