AWS IAM Least Privilege: Policies That Won't Lock You Out
"Least privilege" is universally agreed upon and universally ignored in practice. Teams start with AdministratorAccess because it works, and tightening permissions later is painful — applications break, engineers get locked out, and the remediation is urgent and risky. This guide covers how to build least-privilege IAM policies correctly from the start, with real policy examples and the tools that make it less painful.
The Core Principle
An IAM principal (user, role, service account) should have exactly the permissions required to do its job — no more. This applies at three levels:
- Action scope — which API calls are allowed (
s3:GetObject, nots3:*) - Resource scope — which specific resources (
arn:aws:s3:::my-bucket/*, not*) - Condition scope — constraints on when the permission applies (source IP, MFA required, time windows)
Most teams get level 1 partially right and ignore levels 2 and 3 entirely.
Start With IAM Access Analyzer
Before writing policies manually, use IAM Access Analyzer to understand what permissions are actually being used:
# Enable Access Analyzer in your account
aws accessanalyzer create-analyzer \
--analyzer-name prod-analyzer \
--type ACCOUNT
# Generate a policy based on CloudTrail activity (last 90 days)
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn": "arn:aws:iam::123456789012:role/MyAppRole"}' \
--cloud-trail-details '{
"accessRole": "arn:aws:iam::123456789012:role/AccessAnalyzerRole",
"trailArn": "arn:aws:cloudtrail:us-east-1:123456789012:trail/management-events",
"startTime": "2026-01-01T00:00:00Z",
"endTime": "2026-03-29T00:00:00Z"
}'
This generates a policy based on observed API calls — a much better starting point than guessing.
Pattern 1: Application Service Role
A backend service that reads from S3 and writes to DynamoDB. The common mistake is attaching AmazonS3FullAccess and AmazonDynamoDBFullAccess. The correct approach:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadAppConfig",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-app-config",
"arn:aws:s3:::my-app-config/*"
]
},
{
"Sid": "WriteOrdersTable",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:UpdateItem",
"dynamodb:Query"
],
"Resource": [
"arn:aws:dynamodb:us-east-1:123456789012:table/orders",
"arn:aws:dynamodb:us-east-1:123456789012:table/orders/index/*"
]
},
{
"Sid": "ReadSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/*"
}
]
}
Note:
s3:ListBucketapplies to the bucket ARN (without/*);s3:GetObjectapplies to/*- DynamoDB index access requires the
/index/*ARN separately - Secrets Manager wildcards the path prefix, not
*for all secrets
Pattern 2: CI/CD Deployment Role
A GitHub Actions or GitLab CI role that needs to deploy infrastructure. This is where teams over-provision most aggressively.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageTargetLambda",
"Effect": "Allow",
"Action": [
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:GetFunction",
"lambda:PublishVersion",
"lambda:CreateAlias",
"lambda:UpdateAlias"
],
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:myapp-*"
},
{
"Sid": "PushToECR",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": "*"
},
{
"Sid": "PushImagesToRepo",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:DescribeImages"
],
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/myapp"
},
{
"Sid": "PassRoleToLambda",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::123456789012:role/myapp-lambda-execution-role",
"Condition": {
"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"
}
}
}
]
}
The iam:PassRole condition is critical — it prevents the CI role from passing arbitrary roles to Lambda, restricting it to only the intended execution role.
Pattern 3: Cross-Account Access
Services that need access to resources in another AWS account should use role assumption, not long-lived access keys.
// In the target account (where the S3 bucket lives)
// Trust policy on arn:aws:iam::TARGET_ACCOUNT:role/cross-account-reader
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::SOURCE_ACCOUNT:role/myapp-role"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "unique-external-id-12345"
}
}
}
]
}
# Assume the cross-account role at runtime
aws sts assume-role \
--role-arn "arn:aws:iam::TARGET_ACCOUNT:role/cross-account-reader" \
--role-session-name "myapp-session" \
--external-id "unique-external-id-12345"
The ExternalId condition prevents the confused deputy problem — a third party can't trick your role into assuming the target role without knowing the external ID.
Using Permission Boundaries
Permission boundaries cap what a role can do, regardless of what policies are attached. This is useful for delegating IAM management to teams without letting them escalate their own privileges.
// Permission boundary — the maximum allowed permissions
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:*",
"dynamodb:*",
"lambda:*",
"logs:*",
"cloudwatch:*"
],
"Resource": "*"
},
{
"Effect": "Deny",
"Action": [
"iam:*",
"organizations:*",
"account:*"
],
"Resource": "*"
}
]
}
Attach the boundary when creating roles for teams:
aws iam create-role \
--role-name team-payments-api \
--assume-role-policy-document file://trust-policy.json \
--permissions-boundary arn:aws:iam::123456789012:policy/team-boundary
A team with iam:CreateRole can now create sub-roles — but those sub-roles can never exceed the boundary. They cannot grant themselves or others IAM permissions.
Conditions: The Overlooked Layer
Conditions add context-aware constraints to permissions. Common examples:
Require MFA for sensitive operations:
{
"Effect": "Allow",
"Action": [
"iam:DeleteAccessKey",
"iam:UpdateAccessKey"
],
"Resource": "arn:aws:iam::*:user/${aws:username}",
"Condition": {
"BoolIfExists": {
"aws:MultiFactorAuthPresent": "true"
}
}
}
Restrict to VPC source:
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::internal-data",
"arn:aws:s3:::internal-data/*"
],
"Condition": {
"StringEquals": {
"aws:SourceVpce": "vpce-0a12b3c4d5e6f7890"
}
}
}
Tag-based access control (ABAC):
{
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances"
],
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/Team": "${aws:PrincipalTag/Team}"
}
}
}
This lets engineers start/stop only EC2 instances tagged with the same team as the engineer's IAM principal tag.
Testing Policies Before Applying
Never apply a new restrictive policy to production without testing it first.
# Simulate policy evaluation with the IAM policy simulator
aws iam simulate-principal-policy \
--policy-source-arn "arn:aws:iam::123456789012:role/myapp-role" \
--action-names "s3:GetObject" "s3:PutObject" \
--resource-arns "arn:aws:s3:::my-app-config/config.json"
Output shows allowed or implicitDeny/explicitDeny for each action.
# Check what a role can do with access-advisor
aws iam get-role-policy \
--role-name myapp-role \
--policy-name inline-policy
# List services accessed by the role in the last 90 days
aws iam generate-service-last-accessed-details \
--arn "arn:aws:iam::123456789012:role/myapp-role"
Common IAM Mistakes
| Mistake | Risk | Fix |
|---|---|---|
"Resource": "*" on DynamoDB actions | Any table in any account | Scope to specific table ARNs |
"Action": "s3:*" | Includes DeleteBucket, DeleteObject | Enumerate exactly what's needed |
No resource condition on iam:PassRole | Can pass any role to any service | Add iam:PassedToService condition |
| Wildcard on Secrets Manager | Exposes all secrets | Use path prefix ARN with secret:appname/* |
AdministratorAccess on Lambda execution role | Lambda can call any AWS API | Scope to only what the function needs |
| Access keys on long-lived EC2 instances | Key rotation risk | Use instance profiles (IAM roles) instead |
The Practical Approach
Start with CloudTrail-derived policies from IAM Access Analyzer. Scope to specific resource ARNs. Add conditions for sensitive operations. Apply permission boundaries when delegating IAM management. Test with the policy simulator before deploying.
The goal is not zero-trust perfection on day one — it's systematically reducing the blast radius of a compromised credential. A service role that can only write to one DynamoDB table and read from one S3 bucket is not a meaningful attack vector even if it's compromised. A role with AdministratorAccess is a full account takeover waiting to happen.
Was this article helpful?
SRE & Observability Engineer
If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.
Related Articles
AWS IAM Policy Conditions For Cross-Account Resource Access Without Over-Privileging
Cross-account resource access in AWS is like giving someone the keys to your house—you want them to get in, but only through the right door, at the right t...
Fix AWS S3 'Access Denied' Errors
Systematically troubleshoot and fix AWS S3 Access Denied errors caused by IAM policies, bucket policies, ACLs, and encryption settings.
AWS CLI: Cheat Sheet
AWS CLI cheat sheet with copy-paste commands for EC2, S3, IAM, Lambda, ECS, CloudFormation, SSM, and Secrets Manager operations.
AWS Core Services: The DevOps Engineer's Essential Guide
Navigate the essential AWS building blocks — EC2, S3, VPC, IAM, RDS, Lambda, and EKS explained for DevOps engineers with practical examples.
AWS Lambda Cold Start Optimization Using Provisioned Concurrency And SnapStart
Cold starts are the silent SLO killer in serverless architectures. You've built a beautiful Lambda function, deployed it, and everything looks great in you...
AWS ECS Fargate: Serverless Container Deployment Without Managing Nodes
Deploy containers on AWS ECS Fargate — no EC2 instances to manage. Covers task definitions, services, load balancers, environment variables, secrets from SSM/Secrets Manager, auto-scaling, and CI/CD integration.
More in AWS
View all →AWS EKS: Production Kubernetes Cluster Setup from Scratch
Step-by-step guide to launching a production-ready EKS cluster on AWS — node groups, IAM roles, VPC configuration, managed add-ons, kubeconfig setup, and cost optimization. Both eksctl and Terraform approaches covered.
Fix AWS EC2 Instance 'Status Check Failed' Errors
Diagnose and recover AWS EC2 instances with failed system or instance status checks using stop/start, volume rescue, and auto-recovery techniques.
Cloud Landing Zone Architecture: Account Structure Done Right
How to design an AWS Landing Zone with Organizations, SCPs, and account vending that scales from 5 to 500 accounts without becoming a mess.