When we first started building on AWS, we gave everything AdministratorAccess.
ECS task? AdministratorAccess. Lambda function? AdministratorAccess. CI/CD pipeline? Same thing.
It worked. Everything could talk to everything. We shipped fast. Then we read about a startup whose leaked CI/CD credentials let an attacker spin up $50,000 worth of crypto-mining EC2 instances over a single weekend. The leaked role had AdministratorAccess.
That's when we actually sat down and learned IAM.
This guide is what we wish someone had handed us on day one. No certification jargon, no 47-page docs. Just the mental models, real examples, and copy-paste policies you need to lock down your AWS setup as a web developer.
Why IAM Matters (Even If You're "Just a Developer")
IAM (Identity and Access Management) controls who can do what on which AWS resources. It's not a DevOps-only thing. It's the backbone of your entire AWS security setup.
Every AWS API call goes through IAM. When your ECS container reads from S3, when your Lambda writes to DynamoDB, when your GitHub Actions pipeline pushes new code, IAM is the gatekeeper deciding if the call goes through or gets blocked.
Get it wrong, and you're either:
- Too permissive → one leaked credential = full account compromise
-
Too restrictive → your app crashes with
AccessDeniederrors in production at 2 AM
The goal is least privilege: give exactly the permissions needed, nothing more.
The 4 Core Concepts
1. IAM Users - An Identity for a Person
An IAM user is a person (or application) that interacts with AWS. Each user has permanent, long-term credentials.
IAM User "alice"
├── Console password (for AWS web console)
├── Access Key ID + Secret Key (for CLI/SDK)
└── Attached policies (what Alice can do)
When to use: Rarely. IAM users are considered legacy for human access. Use IAM Identity Center (SSO) instead for your team.
When they're still needed: Break-glass emergency access, or legacy applications that can't use roles.
2. IAM Groups
A group is a collection of users with shared permissions. Instead of attaching policies to each user one by one, attach them to a group.
Group "Developers"
├── Policy: AmazonECS_FullAccess
├── Policy: AmazonS3ReadOnlyAccess
├── User: alice
├── User: bob
└── User: charlie
All three users inherit the group's permissions. When a new developer joins, you add them to the group, no need to configure individual policies.
3. IAM Roles (This Is the Important One) ⭐
A role is like a user, but with three critical differences:
| Feature | IAM User | IAM Role |
|---|---|---|
| Credentials | Long-term (access key + secret) | Short-term (temporary, auto-rotated) |
| Who uses it | A specific person or app | Anyone/anything trusted to "assume" it |
| Credential rotation | Manual (you rotate keys yourself) | Automatic (AWS rotates every 1–12 hours) |
| Security risk if leaked | High (valid until manually revoked) | Low (expires automatically) |
Roles are the recommended way to grant permissions to:
- AWS services (ECS tasks, Lambda functions, EC2 instances)
- CI/CD pipelines (GitHub Actions, GitLab CI)
- Cross-account access (accessing resources in another AWS account)
- Federated users (SSO via Identity Center)
4. IAM Policies
A policy is a JSON document that defines permissions. It answers three questions:
- Effect : Allow or Deny?
-
Action : What operation? (e.g.,
s3:GetObject,ecs:RunTask) - Resource : On which AWS resource? (specified by ARN)
Policies are attached to users, groups or roles. They do nothing on their own, they must be attached to an identity.
Reading an IAM Policy (It's JSON, That's It)
Let's read a real policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadFromSpecificBucket",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-app-uploads",
"arn:aws:s3:::my-app-uploads/*"
]
}
]
}
In plain English: "Allow getting objects and listing the contents of the my-app-uploads S3 bucket."
Breaking It Down
| Field | Value | Meaning |
|---|---|---|
Version |
"2012-10-17" |
Always use this version (it's the current IAM policy language version) |
Sid |
"AllowReadFromSpecificBucket" |
Optional human-readable name |
Effect |
"Allow" |
This statement grants permission (vs. "Deny") |
Action |
["s3:GetObject", "s3:ListBucket"] |
The specific S3 operations allowed |
Resource |
["arn:aws:s3:::my-app-uploads", "arn:aws:s3:::my-app-uploads/*"] |
The specific bucket and its objects |
The ARN Format
Every AWS resource has an Amazon Resource Name:
arn:aws:service:region:account-id:resource-type/resource-id
Examples:
arn:aws:s3:::my-bucket (S3 bucket)
arn:aws:s3:::my-bucket/* (All objects in bucket)
arn:aws:dynamodb:us-east-1:123456789:table/Users (DynamoDB table)
arn:aws:ecs:us-east-1:123456789:cluster/my-app (ECS cluster)
Common Mistake: Wildcards
// ❌ DANGEROUS: Allows ALL actions on ALL resources
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
// ✅ SAFE: Allows only specific actions on specific resources
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::my-app-uploads/*"]
}
Rule of thumb: If you see
"Action": "*"or"Resource": "*"in a production policy, something is wrong.
How Roles Actually Work (The Mental Model)
Every IAM role has two policies:
1. Trust Policy - WHO Can Assume This Role
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Translation: "ECS tasks are allowed to assume this role." No other service, user, or account can use it.
2. Permission Policy - WHAT the Role Can Do
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-app-uploads/*"
}
]
}
Translation: "Whoever assumes this role can read and write objects in the my-app-uploads bucket."
The Assumption Flow
- Your ECS task starts and calls AWS Security Token Service (STS):
sts:AssumeRole - STS checks the trust policy - is
ecs-tasks.amazonaws.comallowed? ✅ - STS returns temporary credentials (access key + secret + session token) valid for 1–12 hours
- Your container uses these credentials to access S3
- When the credentials expire, ECS automatically requests new ones
You never manage, rotate, or store these credentials. AWS handles everything.
5 Real-World Examples (Copy-Paste Ready)
Example 1: ECS Task Role (Your App Accesses S3 + DynamoDB)
Probably the most common role you'll create. Your containerized app needs to read/write to S3 and query DynamoDB.
Trust Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Permission Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3Access",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::my-app-uploads/*"
},
{
"Sid": "S3ListBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-app-uploads"
},
{
"Sid": "DynamoDBAccess",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:us-east-1:*:table/my-app-*"
}
]
}
Create it with CLI:
# Create the role
aws iam create-role \
--role-name MyAppTaskRole \
--assume-role-policy-document file://trust-policy.json
# Create and attach the permission policy
aws iam put-role-policy \
--role-name MyAppTaskRole \
--policy-name MyAppPermissions \
--policy-document file://permission-policy.json
Use it in your ECS task definition:
{
"family": "my-web-app",
"taskRoleArn": "arn:aws:iam::123456789:role/MyAppTaskRole",
"executionRoleArn": "arn:aws:iam::123456789:role/ecsTaskExecutionRole",
"containerDefinitions": [...]
}
⚠️ Task Role vs. Execution Role: Don't confuse these. The Task Role is used by your app code (S3, DynamoDB). The Execution Role is used by the ECS agent (pulling images from ECR, writing to CloudWatch). They are separate roles with separate permissions.
Example 2: ECS Task Execution Role (Pulling Images + Logging)
This is the role the ECS agent uses, not your application code.
Trust Policy: Same as above (ECS tasks service principal).
Permission Policy: Use the AWS managed policy:
aws iam attach-role-policy \
--role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
This managed policy includes:
-
ecr:GetAuthorizationToken: authenticate with ECR -
ecr:BatchGetImage: pull container images -
logs:CreateLogStream: create CloudWatch log streams -
logs:PutLogEvents: write logs
If you also need to read secrets from Secrets Manager (for database passwords, API keys):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": [
"arn:aws:secretsmanager:us-east-1:*:secret:my-app/*"
]
}
]
}
Example 3: GitHub Actions CI/CD Role (OIDC, No Access Keys)
This is the right way to deploy from GitHub Actions to AWS in 2026. Instead of storing long-lived AWS access keys in GitHub secrets, you use OIDC federation.
Step 1: Create the OIDC Provider (one-time setup)
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Step 2: Create the Role with a Trust Policy scoped to your repo
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789: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:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
Pay attention to the Condition block. It locks this role down to the main branch of your specific repo. A fork, a PR branch, or a different repo can't assume it.
Step 3: Permission Policy (deploy-only)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ECRPushAccess",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "*"
},
{
"Sid": "ECSDeployAccess",
"Effect": "Allow",
"Action": [
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition"
],
"Resource": "*"
},
{
"Sid": "PassRoleToECS",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::123456789:role/MyAppTaskRole",
"arn:aws:iam::123456789:role/ecsTaskExecutionRole"
]
}
]
}
Use it in GitHub Actions:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsDeployRole
aws-region: us-east-1
No access keys. No secrets. Temporary credentials that expire automatically. ✅
Example 4: Lambda Function Role
Your Lambda function needs to process images from S3 and send notifications via SNS.
Trust Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Permission Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::image-uploads/*"
},
{
"Effect": "Allow",
"Action": ["sns:Publish"],
"Resource": "arn:aws:sns:us-east-1:*:image-processed"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:*:*"
}
]
}
Example 5: Cross-Account Access Role
Your staging account needs to pull Docker images from your production ECR repository.
Trust Policy (on the production account role):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::STAGING_ACCOUNT_ID:root"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "staging-access-2026"
}
}
}
]
}
The ExternalId prevents the "confused deputy" problem, ensuring only your staging account (not a random third party) can assume this role.
Task Role vs. Execution Role (ECS Cheat Sheet)
This trips up almost every web developer who starts using ECS:
| Task Role | Execution Role | |
|---|---|---|
| Who uses it | Your application code | The ECS agent |
| When it's used | While your container is running | During task launch (pulling image, starting container) |
| Typical permissions | S3, DynamoDB, SQS, SNS, SES | ECR (pull images), CloudWatch Logs, Secrets Manager |
| If missing |
AccessDenied when your app calls AWS services |
CannotPullContainerError at task startup |
| Task definition field | taskRoleArn |
executionRoleArn |
Think of it this way: the Execution Role is the building manager who lets the tenant move in (pulls image, sets up logs). The Task Role is the tenant's key card that opens specific doors (S3, DynamoDB) inside the building.
The 10 IAM Commandments (2026 Best Practices)
1. ❌ Never Use "Action": "*" or "Resource": "*" in Production
// ❌ The "I'll fix it later" policy (you won't)
{ "Effect": "Allow", "Action": "*", "Resource": "*" }
// ✅ Specific actions, specific resources
{ "Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::my-bucket/*"] }
2. ✅ Use Roles Instead of Users for Everything
| Use Case | ❌ Old Way | ✅ Modern Way |
|---|---|---|
| ECS container accesses S3 | Access keys in env vars | Task Role |
| Lambda accesses DynamoDB | Access keys in code | Lambda execution role |
| GitHub Actions deploys to ECS | Access keys in secrets | OIDC federation |
| Developer accesses AWS console | IAM user + password | Identity Center (SSO) |
3. ✅ Enable MFA on the Root Account (Day 1)
# Check if root has MFA
aws iam get-account-summary --query "SummaryMap.AccountMFAEnabled"
# Should return: 1
Your root account can delete your entire AWS infrastructure. Protect it with MFA and never use it for daily work.
4. ✅ Use AWS Managed Policies When They Fit
AWS maintains pre-built policies for common scenarios:
| Policy Name | What It Grants |
|---|---|
AmazonS3ReadOnlyAccess |
Read-only access to all S3 buckets |
AmazonDynamoDBFullAccess |
Full access to DynamoDB |
AmazonECSTaskExecutionRolePolicy |
ECR pull + CloudWatch Logs |
CloudWatchLogsFullAccess |
Full CloudWatch Logs access |
# List all AWS managed policies
aws iam list-policies --scope AWS --query "Policies[].PolicyName" | head -50
But for production, prefer custom policies scoped to specific resources.
5. ✅ Use IAM Access Analyzer
Access Analyzer finds unused permissions, external access, and overly broad policies:
aws accessanalyzer create-analyzer \
--analyzer-name my-account-analyzer \
--type ACCOUNT
# Check findings
aws accessanalyzer list-findings \
--analyzer-arn arn:aws:access-analyzer:us-east-1:123456789:analyzer/my-account-analyzer
6. ✅ Tag Your Roles
aws iam tag-role \
--role-name MyAppTaskRole \
--tags Key=Environment,Value=production Key=Service,Value=my-web-app Key=ManagedBy,Value=terraform
Tags make auditing and cost tracking way easier down the line.
7. ✅ Use Conditions for Extra Security
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "arn:aws:s3:::sensitive-data/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "203.0.113.0/24"
},
"Bool": {
"aws:MultiFactorAuthPresent": "true"
}
}
}
This allows S3 access only from a specific IP range AND only when MFA is active.
8. ✅ Audit with CloudTrail
Every IAM action is logged in CloudTrail. Use it to investigate:
# Who assumed a role in the last 24 hours?
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
--start-time $(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) \
--max-items 10
9. ✅ Rotate Credentials Regularly
If you must use IAM users with access keys (avoid if possible):
# List access keys and their age
aws iam list-access-keys --user-name alice
# Create new key, update apps, then delete old key
aws iam create-access-key --user-name alice
aws iam delete-access-key --user-name alice --access-key-id AKIA_OLD_KEY
10. ✅ Use Separate Roles for Each Service
Don't share one role between your web app, worker, and cron job. Each service should have its own role with only the permissions it needs.
my-app-web-task-role → S3 read, DynamoDB read/write
my-app-worker-task-role → SQS consume, S3 read/write
my-app-cron-task-role → DynamoDB read, SES send
If the worker is compromised, the attacker can't access DynamoDB or send emails. Blast radius minimized.
Debugging IAM Issues
When your app throws AccessDenied, here's how to figure out what went wrong:
Step 1: Read the Error Message
An error occurred (AccessDeniedException) when calling the GetItem operation:
User: arn:aws:sts::123456789:assumed-role/MyAppTaskRole/abc123
is not authorized to perform: dynamodb:GetItem
on resource: arn:aws:dynamodb:us-east-1:123456789:table/Users
This tells you exactly:
-
Who tried:
MyAppTaskRole -
What they tried:
dynamodb:GetItem -
On what:
table/Users
Step 2: Check the Policy
# List policies attached to the role
aws iam list-attached-role-policies --role-name MyAppTaskRole
aws iam list-role-policies --role-name MyAppTaskRole
# View a specific inline policy
aws iam get-role-policy --role-name MyAppTaskRole --policy-name MyAppPermissions
Step 3: Simulate the Policy
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789:role/MyAppTaskRole \
--action-names dynamodb:GetItem \
--resource-arns arn:aws:dynamodb:us-east-1:123456789:table/Users
This tells you if the permission would be allowed or denied without actually making the call.
Step 4: Check for Deny Policies
Remember: an explicit Deny always overrides an Allow. Check for:
- Service Control Policies (SCPs) at the organization level
- Permission boundaries on the role
- Resource-based policies on the target service
How TurboDeploy Handles IAM
When you deploy through TurboDeploy, we create least-privilege IAM roles in your AWS account:
| Role | Purpose | Permissions |
|---|---|---|
TurboDeployServiceRole |
TurboDeploy manages your infrastructure | ECS, ECR, ALB, Route 53 (scoped to your resources) |
{app}-TaskRole |
Your app's runtime permissions | Only what your app needs (S3, DB, etc.) |
{app}-ExecutionRole |
ECS agent role | ECR pull + CloudWatch Logs |
{app}-CICDRole |
GitHub Actions deploy | ECR push + ECS update (OIDC, no keys) |
Every role is scoped to specific resources (no wildcards), tagged with your app name and environment, and visible in your AWS console. You can audit everything through CloudTrail.
We never ask for AdministratorAccess. We never store your credentials. Everything runs in your account.
TL;DR
| Concept | What to Remember |
|---|---|
| Users | Legacy for humans. Use Identity Center (SSO) instead. |
| Groups | Organize users. Attach policies to groups, not users. |
| Roles | Use for everything. Services, CI/CD, cross-account. Temporary creds. |
| Policies | JSON rules. Effect + Action + Resource. No wildcards in prod. |
| Trust Policy | WHO can assume the role (the Principal). |
| Permission Policy | WHAT the role can do (the Actions + Resources). |
| Task Role | Your app code uses this (S3, DynamoDB, SQS). |
| Execution Role | ECS agent uses this (ECR pull, CloudWatch Logs). |
| OIDC | Modern CI/CD auth. No access keys. GitHub Actions → STS → temp creds. |
| Least privilege | Always. If "Action": "*", you've already failed. |
Don't want to deal with IAM policies? TurboDeploy creates least-privilege roles for every deployment, scoped to your resources and visible in your console. No IAM expertise needed.



Top comments (0)