AWS IAM: Roles, Policies, and Trust Relationships

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

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.

Contents