What Is IAM?
IAM (Identity and Access Management) controls who can do what on AWS. Every API call is authorized by IAM. Whether it's a human in the console, a CLI script, a Lambda function, or an EC2 instance.
IAM is not your application's authorization system (that's a different problem. See my blog post on this). IAM controls access to AWS resources: who can create an S3 bucket, who can invoke a Lambda function, who can read from a DynamoDB table.
Core Concepts
Principals
Who is making the request:
- IAM Users: Long-lived credentials (access key + secret). Use for human access only when SSO isn't available.
- IAM Roles: Temporary credentials assumed by services, applications, or federated users. The default for everything in production.
- AWS Services: Lambda, ECS, EC2. They assume roles to call other AWS services.
Policies
What actions are allowed/denied on which resources:
- Identity-based policies: Attached to users, groups, or roles
- Resource-based policies: Attached to the resource (S3 bucket policy, SQS queue policy, KMS key policy)
- Permission boundaries: Maximum permissions an identity can have (caps what identity policies can grant)
- Service Control Policies (SCPs): Organization-wide guardrails
Evaluation logic
AWS evaluates policies in this order:
- SCPs (org-level). If denied here, stopped
- Resource-based policy. Can grant cross-account access
- Permission boundaries. Caps identity permissions
- Identity-based policies. What the caller is explicitly allowed to do
- Session policies (for assumed roles)
Default deny. Unless something explicitly allows an action, it's denied. An explicit deny anywhere in the chain overrides any allow.
Least-Privilege Patterns
For Lambda functions
Each Lambda function gets its own execution role with only the permissions it needs:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders"
},
{
"Effect": "Allow",
"Action": ["sqs:SendMessage"],
"Resource": "arn:aws:sqs:us-east-1:123456789012:notifications"
}
]
}
Not dynamodb:*. Not Resource: "*". Specific actions on specific resources.
For ECS tasks
Separate the task role (what the application can do) from the task execution role (what ECS needs to pull images and write logs):
- Task role: access to DynamoDB, S3, SQS. Your application's permissions
- Execution role: access to ECR (pull images), CloudWatch Logs (write logs), Secrets Manager (inject secrets)
Using CDK's grant methods
CDK makes least-privilege easy:
// This creates a policy with exactly the actions needed
table.grantReadData(lambdaFunction); // GetItem, Query, Scan, BatchGetItem
queue.grantSendMessages(lambdaFunction); // SendMessage, SendMessageBatch
bucket.grantRead(lambdaFunction); // GetObject, ListBucket
secret.grantRead(lambdaFunction); // GetSecretValue
Each .grant* method creates a scoped policy. You never write raw JSON unless you need something custom.
Roles vs Users
Always prefer roles:
- Roles provide temporary credentials (expire in 1-12 hours)
- No long-lived secrets to leak or rotate
- Can be assumed by services, other accounts, or federated users
Users are for:
- CI/CD systems that can't assume roles (legacy)
- Break-glass emergency access
- Local development when SSO isn't configured
If you have IAM users with access keys in production workloads, that's a security finding.
Cross-Account Access
Role assumption
Account A creates a role with a trust policy allowing Account B to assume it:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::222222222222:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "shared-secret-id" }
}
}]
}
Account B's workloads call sts:AssumeRole to get temporary credentials for Account A.
Resource-based policies
Some services (S3, SQS, SNS, KMS, Lambda) support resource-based policies that directly grant cross-account access without role assumption:
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::222222222222:role/DataProcessor" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::shared-data-bucket/*"
}
Common Mistakes
Wildcard resources
"Resource": "*"
This grants the action on ALL resources of that type in your account. Never do this in production unless the action genuinely applies globally (like logs:CreateLogGroup).
Overly broad actions
"Action": "s3:*"
Your Lambda that reads files doesn't need s3:DeleteBucket. Grant specific actions.
Shared roles across functions
One role for all Lambda functions means every function can access every resource. Create per-function roles (CDK does this by default).
Not using conditions
Conditions restrict when a policy applies:
"Condition": {
"StringEquals": { "aws:RequestedRegion": "us-east-1" },
"IpAddress": { "aws:SourceIp": "10.0.0.0/8" }
}
Service Control Policies (SCPs)
For multi-account organizations, SCPs set guardrails that no one (not even account admins) can bypass:
{
"Effect": "Deny",
"Action": [
"ec2:RunInstances"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"ec2:Region": ["us-east-1", "us-west-2"]
}
}
}
This denies launching EC2 instances outside allowed regions, regardless of what IAM policies exist in the account.
Further Reading
Related Blog Posts
Looking for hands-on help? View my AWS architecture services β