Skip to main content
AWS·10 min read

AWS IAM Least Privilege: Stop Giving Your Services Admin Access

A practical guide to implementing least privilege IAM policies in AWS. Covers policy structure, common mistakes, AWS Access Analyzer, and real Terraform examples for creating properly scoped roles.

DT

DevOps Engineer & Technical Writer

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.

AWS IAM POLICY EVALUATION FLOW PRINCIPAL User / Role Makes API call s3:GetObject REQUEST CONTEXT Action Resource ARN Conditions Source IP Region MFA status POLICY EVALUATION ENGINE 1. Explicit Deny? 2. SCPs (Org level) 3. Permission Boundaries 4. Identity Policies 5. Resource Policies ALLOW All checks pass DENY Any check fails Default: implicit deny. Access must be explicitly allowed at every layer.

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:

  • Start with Access Analyzer: Generate policies from actual usage after 30 days of CloudTrail data.
  • Add deny statements: Explicitly deny dangerous actions (iam:CreateUser, organizations:LeaveOrganization) at the permission boundary level.
  • Use conditions: Restrict by region, source account, VPC, tags, and time.
  • Enable CloudTrail + Athena: Query who used which permissions and when.
  • Set up unused access alerts: Access Analyzer flags permissions granted but never used.
  • # 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

    ServiceRead-OnlyWriteCommon 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.

    ---