The Problem With "Just Give It Admin"
Every security breach post-mortem I've read in the last five years has one thing in common: over-permissioned IAM roles. A Lambda function that only needs to read from one S3 bucket gets s3: on . A CI/CD pipeline that deploys one service gets AdministratorAccess. Then someone finds an SSRF vulnerability, and suddenly the attacker has the keys to everything.
Least privilege isn't just a compliance checkbox. It's the difference between "we had a minor incident" and "we're on the front page of Hacker News."
Understanding IAM Policy Structure
Every IAM policy has the same anatomy. Master this, and you can write secure policies in your sleep:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3ReadSpecificBucket",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-app-data-prod",
"arn:aws:s3:::my-app-data-prod/*"
],
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "us-east-1"
}
}
}
]
}
Key principles:
- Effect: Always explicit. Deny wins over Allow.
- Action: Specific API calls, never wildcards in production.
- Resource: ARNs scoped to exactly what's needed.
- Condition: Further restrict based on context (region, tags, source IP, time).
The 5 Most Common IAM Mistakes
1. Wildcard Resources
// DON'T
"Resource": "*"
// DO
"Resource": "arn:aws:s3:::my-specific-bucket/*"
2. Wildcard Actions
// DON'T
"Action": "s3:*"
// DO
"Action": ["s3:GetObject", "s3:PutObject"]
3. Not Using Conditions
If your Lambda only runs in us-east-1, enforce it:
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "us-east-1"
}
}
4. Shared Roles Across Services
Every service should have its own IAM role. One role per Lambda, one role per ECS task, one role per EC2 instance profile. Period.
5. Not Using Permission Boundaries
Permission boundaries cap what a role can ever do, even if someone attaches AdministratorAccess to it later:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:*",
"dynamodb:*",
"logs:*",
"sqs:*"
],
"Resource": "*"
},
{
"Effect": "Deny",
"Action": [
"iam:*",
"organizations:*",
"account:*"
],
"Resource": "*"
}
]
}
Using AWS Access Analyzer
Access Analyzer does two critical things: identifies resources shared externally and generates least-privilege policies from CloudTrail logs.
Generating Policies From Actual Usage
# Enable CloudTrail logging first (you should already have this)
aws accessanalyzer start-policy-generation \
--policy-generation-details '{
"principalArn": "arn:aws:iam::123456789012:role/my-lambda-role",
"cloudTrailDetails": {
"trails": [
{
"cloudTrailArn": "arn:aws:cloudtrail:us-east-1:123456789012:trail/management-trail",
"regions": ["us-east-1"],
"allRegions": false
}
],
"accessRole": "arn:aws:iam::123456789012:role/AccessAnalyzerRole",
"startTime": "2026-05-01T00:00:00Z",
"endTime": "2026-06-01T00:00:00Z"
}
}'
This examines 30 days of actual API calls made by the role and generates a policy that covers exactly what was used. No more, no less.
Validating Policies
aws accessanalyzer validate-policy \
--policy-document file://policy.json \
--policy-type IDENTITY_POLICY
This catches issues like:
- Actions that don't exist
- Resources that don't match the action's service
- Missing conditions that could tighten the policy
- Overly permissive patterns
Real Terraform Examples
Lambda Function With Scoped Permissions
# IAM role for the Lambda function
resource "aws_iam_role" "order_processor" {
name = "order-processor-lambda-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
Condition = {
StringEquals = {
"aws:SourceAccount" = data.aws_caller_identity.current.account_id
}
}
}
]
})
permissions_boundary = aws_iam_policy.lambda_boundary.arn
tags = {
Service = "order-processing"
Environment = var.environment
ManagedBy = "terraform"
}
}
# Scoped policy: only what this Lambda actually needs
resource "aws_iam_role_policy" "order_processor" {
name = "order-processor-permissions"
role = aws_iam_role.order_processor.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ReadOrdersFromSQS"
Effect = "Allow"
Action = [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes"
]
Resource = aws_sqs_queue.orders.arn
},
{
Sid = "WriteToOrdersTable"
Effect = "Allow"
Action = [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:GetItem"
]
Resource = [
aws_dynamodb_table.orders.arn,
"${aws_dynamodb_table.orders.arn}/index/*"
]
},
{
Sid = "PublishOrderEvents"
Effect = "Allow"
Action = [
"sns:Publish"
]
Resource = aws_sns_topic.order_events.arn
},
{
Sid = "WriteLogsOnly"
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:${var.region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/order-processor-${var.environment}:*"
}
]
})
}
ECS Task Role With Cross-Account Access
resource "aws_iam_role" "api_task" {
name = "api-task-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Condition = {
ArnLike = {
"aws:SourceArn" = "arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:*"
}
}
}
]
})
}
resource "aws_iam_role_policy" "api_task" {
name = "api-task-permissions"
role = aws_iam_role.api_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ReadSecrets"
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue"
]
Resource = [
"arn:aws:secretsmanager:${var.region}:${data.aws_caller_identity.current.account_id}:secret:${var.environment}/api/*"
]
},
{
Sid = "DecryptWithKMS"
Effect = "Allow"
Action = [
"kms:Decrypt"
]
Resource = aws_kms_key.secrets.arn
},
{
Sid = "S3UploadOnly"
Effect = "Allow"
Action = [
"s3:PutObject"
]
Resource = "${aws_s3_bucket.uploads.arn}/users/*"
Condition = {
StringEquals = {
"s3:x-amz-server-side-encryption" = "aws:kms"
}
}
}
]
})
}
CI/CD Deployment Role (GitHub Actions OIDC)
# OIDC provider for GitHub Actions
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
# Deployment role - scoped to specific repo and branch
resource "aws_iam_role" "github_deploy" {
name = "github-actions-deploy-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:myorg/myapp:ref:refs/heads/main"
}
}
}
]
})
}
resource "aws_iam_role_policy" "github_deploy" {
name = "deploy-permissions"
role = aws_iam_role.github_deploy.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ECRPushOnly"
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = aws_ecr_repository.app.arn
},
{
Sid = "UpdateECSService"
Effect = "Allow"
Action = [
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition"
]
Resource = "*"
Condition = {
StringEquals = {
"aws:ResourceTag/Environment" = var.environment
}
}
},
{
Sid = "PassTaskRoles"
Effect = "Allow"
Action = "iam:PassRole"
Resource = [
aws_iam_role.api_task.arn,
aws_iam_role.ecs_execution.arn
]
}
]
})
}
The Iterative Approach
You don't need to get least privilege perfect on day one. Here's the process:
iam:CreateUser, organizations:LeaveOrganization) at the permission boundary level.# Query unused permissions with Access Analyzer
aws accessanalyzer list-findings \
--analyzer-arn "arn:aws:access-analyzer:us-east-1:123456789012:analyzer/my-analyzer" \
--filter '{"resourceType": {"eq": ["AWS::IAM::Role"]}, "status": {"eq": ["ACTIVE"]}}'
Quick Reference: Common Service Permissions
| Service | Read-Only | Write | Common Mistake |
|---|---|---|---|
| S3 | <code class="inline-code">s3:GetObject</code>, <code class="inline-code">s3:ListBucket</code> | <code class="inline-code">s3:PutObject</code> | Using <code class="inline-code">s3:*</code> |
| DynamoDB | <code class="inline-code">dynamodb:GetItem</code>, <code class="inline-code">dynamodb:Query</code> | <code class="inline-code">dynamodb:PutItem</code>, <code class="inline-code">dynamodb:UpdateItem</code> | Missing index ARNs |
| SQS | <code class="inline-code">sqs:ReceiveMessage</code> | <code class="inline-code">sqs:SendMessage</code> | Forgetting <code class="inline-code">sqs:DeleteMessage</code> |
| SNS | <code class="inline-code">sns:Subscribe</code> | <code class="inline-code">sns:Publish</code> | Publishing to <code class="inline-code">*</code> |
| Lambda | <code class="inline-code">lambda:GetFunction</code> | <code class="inline-code">lambda:InvokeFunction</code> | <code class="inline-code">lambda:*</code> on all functions |
Final Thought
The best time to implement least privilege was when the role was created. The second best time is now. Start with your most critical services — the ones handling customer data, payments, or PII. Use Access Analyzer to baseline actual usage. Then tighten from there.
Every role you scope down is one less attack surface an adversary can exploit. The extra 15 minutes writing a proper policy is worth it.
---
Frequently Asked Questions
What is the principle of least privilege in AWS IAM?
Least privilege means granting only the minimum permissions required for a user, role, or service to perform its specific task. This reduces the blast radius of compromised credentials and limits accidental damage. Start with zero permissions and add only what is needed, rather than starting broad and trying to restrict later.
How do I find unused IAM permissions to remove?
Use AWS IAM Access Analyzer to generate policies based on actual API activity from CloudTrail logs. The aws iam generate-service-last-accessed-details command shows which services a role has actually used. Review these reports monthly and remove permissions for services that haven't been accessed in 90+ days.
What is the difference between IAM policies and resource-based policies?
IAM policies attach to users, groups, or roles and define what actions the principal can perform. Resource-based policies attach to resources like S3 buckets or SQS queues and define who can access them. Resource-based policies enable cross-account access without assuming roles and are evaluated together with IAM policies.
How do I audit IAM permissions across all AWS accounts?
Use AWS Organizations with IAM Access Analyzer set to organization scope to identify resources shared externally. Combine with AWS Config rules to detect overly permissive policies and SCPs (Service Control Policies) to set permission boundaries across accounts. Tools like Prowler and ScoutSuite provide automated audit reports.
Why is my IAM role getting "Access Denied" even with the right policy?
Check for explicit deny statements in SCPs, permission boundaries, or session policies that override allows. Verify the resource ARN matches exactly — wildcards in the wrong position cause mismatches. Also check condition keys like IP restrictions, MFA requirements, or time-based conditions that might block the request.
---
Related Resources
- Production Reference Architectures — Multi-region and serverless AWS architectures
- Certification Exam Prep — AWS certification exam prep guides
- AWS IAM Role Assume Guide — Cross-account role assumption patterns
- AWS S3 Bucket Policy Examples — Least privilege applied to S3 policies
- Terraform State Management — IAM for Terraform state backend access