Back to Blogs

AWS IAM Least Privilege: A Practical Guide

AWS Security October 12, 2023

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:

  1. Enable IAM Access Analyzer today
  2. Implement SCPs as organizational guardrails
  3. Replace IAM users with SSO and roles
  4. Use permission boundaries for delegated administration
  5. Audit IAM monthly with automated scripts
  6. 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.