DevOpsil
AWS
93%
Needs Review

AWS IAM Least Privilege: Policies That Won't Lock You Out

Riku TanakaRiku Tanaka6 min read

"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:

  1. Action scope — which API calls are allowed (s3:GetObject, not s3:*)
  2. Resource scope — which specific resources (arn:aws:s3:::my-bucket/*, not *)
  3. 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:ListBucket applies to the bucket ARN (without /*); s3:GetObject applies 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

MistakeRiskFix
"Resource": "*" on DynamoDB actionsAny table in any accountScope to specific table ARNs
"Action": "s3:*"Includes DeleteBucket, DeleteObjectEnumerate exactly what's needed
No resource condition on iam:PassRoleCan pass any role to any serviceAdd iam:PassedToService condition
Wildcard on Secrets ManagerExposes all secretsUse path prefix ARN with secret:appname/*
AdministratorAccess on Lambda execution roleLambda can call any AWS APIScope to only what the function needs
Access keys on long-lived EC2 instancesKey rotation riskUse 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.

Share:

Was this article helpful?

Riku Tanaka
Riku Tanaka

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

AWSQuick RefBeginnerNeeds Review

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.

Sarah Chen·
3 min read
AWSQuick RefBeginnerNeeds Review

AWS CLI: Cheat Sheet

AWS CLI cheat sheet with copy-paste commands for EC2, S3, IAM, Lambda, ECS, CloudFormation, SSM, and Secrets Manager operations.

Dev Patel·
3 min read

More in AWS

View all →
AWSTutorialBeginnerNeeds Review

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.

Aareez Asif·
6 min read

Discussion