If there's one security principle that could prevent 80% of cloud security incidents, it's least privilege. In my years of consulting, I've seen IAM policies that grant *:* to production services, shared root credentials across teams, and users with AdministratorAccess "just in case." This guide is my battle-tested approach to implementing least privilege IAM in AWS.
Common IAM Anti-Patterns
Before we talk about what to do, let's cover what I see go wrong most often:
- Wildcard everything:
"Action": "*", "Resource": "*"on production roles - Shared IAM users: Multiple people using the same access keys
- Never rotating keys: Access keys that haven't been rotated in years
- No MFA: Console access without multi-factor authentication
- Over-permissioned CI/CD: Deployment pipelines with admin access
Starting with IAM Access Analyzer
IAM Access Analyzer is your best friend for right-sizing policies. It analyzes CloudTrail logs to determine which permissions are actually used:
# Generate a policy based on actual usage (last 90 days)
aws accessanalyzer generate-policy \
--policy-generation-details '{
"principalArn": "arn:aws:iam::123456789012:role/my-app-role"
}' \
--cloud-trail-details '{
"trails": [{
"cloudTrailArn": "arn:aws:cloudtrail:us-east-1:123456789012:trail/my-trail",
"regions": ["us-east-1"],
"allRegions": false
}],
"startTime": "2023-07-01T00:00:00Z",
"endTime": "2023-10-01T00:00:00Z"
}'
Writing Fine-Grained IAM Policies
Here's a real example from a client project. Instead of granting broad S3 access, we scoped it down to exactly what the application needs:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadAppConfig",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetObjectVersion"
],
"Resource": "arn:aws:s3:::myapp-config/production/*"
},
{
"Sid": "WriteUserUploads",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::myapp-uploads/${aws:PrincipalTag/team}/*",
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "aws:kms"
}
}
},
{
"Sid": "ListBucketContents",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": [
"arn:aws:s3:::myapp-config",
"arn:aws:s3:::myapp-uploads"
],
"Condition": {
"StringLike": {
"s3:prefix": ["production/*", "${aws:PrincipalTag/team}/*"]
}
}
}
]
}
Service Control Policies (SCPs)
SCPs are organizational guardrails that prevent even admin users from doing certain things. Here's my standard baseline SCP:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyRegionsOutsideAllowed",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["us-east-1", "us-west-2", "eu-west-1"]
},
"ForAnyValue:StringNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::*:role/OrganizationAdmin"
]
}
}
},
{
"Sid": "DenyRootAccount",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"StringLike": {
"aws:PrincipalArn": "arn:aws:iam::*:root"
}
}
},
{
"Sid": "RequireIMDSv2",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringNotEquals": {
"ec2:MetadataHttpTokens": "required"
}
}
}
]
}
Permission Boundaries
Permission boundaries are crucial for delegated administration. They let you give a team the ability to create IAM roles without the risk of privilege escalation:
# Terraform: Create a permission boundary
resource "aws_iam_policy" "developer_boundary" {
name = "DeveloperPermissionBoundary"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:*",
"dynamodb:*",
"lambda:*",
"logs:*",
"sqs:*",
"sns:*"
]
Resource = "*"
},
{
Effect = "Deny"
Action = [
"iam:CreateUser",
"iam:CreateRole",
"organizations:*",
"account:*"
]
Resource = "*"
}
]
})
}
# Developers can only create roles within this boundary
resource "aws_iam_policy" "developer_role_creation" {
name = "DeveloperRoleCreation"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "iam:CreateRole"
Resource = "*"
Condition = {
StringEquals = {
"iam:PermissionsBoundary" = aws_iam_policy.developer_boundary.arn
}
}
}]
})
}
IAM Roles vs Users
My general rules:
- Use IAM roles for everything: EC2 instances, Lambda functions, ECS tasks, CI/CD pipelines
- Use IAM Identity Center (SSO) for human access to the console
- Avoid IAM users wherever possible. If you must create them, enforce MFA and key rotation
- Never use root credentials for anything. Lock them away with MFA and a hardware token.
Cross-Account Access
For multi-account architectures (which I strongly recommend), use role assumption:
# In the target account - trust the source account's role
resource "aws_iam_role" "cross_account" {
name = "CrossAccountDeployRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::SOURCE_ACCOUNT_ID:role/cicd-pipeline"
}
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"sts:ExternalId" = "unique-external-id-here"
}
}
}]
})
}
Auditing IAM with CloudTrail
Regular auditing is essential. Here are the queries I run monthly for clients:
# Find unused IAM users (no login in 90 days)
aws iam generate-credential-report
aws iam get-credential-report --output text --query Content | \
base64 --decode | \
awk -F, '$5 != "N/A" && $5 < "'$(date -d '-90 days' +%Y-%m-%d)'" {print $1, $5}'
# Find overly permissive policies
aws iam list-policies --scope Local --query 'Policies[*].Arn' --output text | \
while read arn; do
version=$(aws iam get-policy --policy-arn $arn --query 'Policy.DefaultVersionId' --output text)
aws iam get-policy-version --policy-arn $arn --version-id $version \
--query 'PolicyVersion.Document' --output json | \
grep -l '"Action": "\*"' && echo "WILDCARD FOUND: $arn"
done
# List all access keys and their age
aws iam list-users --query 'Users[*].UserName' --output text | \
while read user; do
aws iam list-access-keys --user-name $user \
--query 'AccessKeyMetadata[*].[UserName,AccessKeyId,CreateDate,Status]' \
--output table
done
Real-World Impact
During a compliance audit for a fintech client, we discovered that a single IAM user had AdministratorAccess and its access keys were embedded in a public GitHub repository. The keys had been exposed for 3 weeks. Fortunately, the account had SCPs that limited the blast radius. Without those guardrails, the entire AWS account could have been compromised.
This incident reinforced three things:
- SCPs are your safety net when everything else fails
- Never use long-lived credentials when short-lived alternatives exist
- Automated secret scanning in CI/CD is not optional
Conclusion
IAM least privilege is a journey, not a destination. Start with these steps:
- Enable IAM Access Analyzer today
- Implement SCPs as organizational guardrails
- Replace IAM users with SSO and roles
- Use permission boundaries for delegated administration
- Audit IAM monthly with automated scripts
- Treat IAM policy changes as code changes - review them in PRs
The effort to implement least privilege pays dividends every time you avoid a security incident.