Skip to content
Codeloom
AWS

AWS IAM Least Privilege: Write Secure Policies

Master AWS IAM least privilege principles. Learn to write secure IAM policies, use conditions, and avoid common mistakes that lead to over-permissioned roles.

·8 min read · By Codeloom
Intermediate 17 min read

What you'll learn

  • Write IAM policies that follow least privilege principles
  • Use policy conditions to restrict access by IP, time, and tags
  • Analyze and reduce permissions with IAM Access Analyzer
  • Avoid the most common IAM security mistakes

Prerequisites

  • AWS account with IAM admin access
  • Basic familiarity with JSON syntax

The principle of least privilege means granting only the permissions required to perform a task and nothing more. In AWS, this translates directly to IAM policies. Overly permissive policies are the root cause of most AWS security incidents, and tightening them is one of the highest-impact security improvements you can make.

This guide shows you how to write secure IAM policies, use conditions effectively, and leverage AWS tools to identify and eliminate excess permissions.

How IAM Policies Work

IAM policies are JSON documents that define permissions. Every API call in AWS is evaluated against applicable policies to determine whether it should be allowed or denied.

A policy has four main elements:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-bucket/*",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "us-east-1"
        }
      }
    }
  ]
}
  • Effect: Allow or Deny
  • Action: The API actions this statement applies to
  • Resource: The ARN(s) of resources this statement applies to
  • Condition (optional): Additional constraints that must be met

AWS evaluates policies with an explicit deny winning over any allow. If no policy explicitly allows an action, it is implicitly denied.

The Problem with Wildcard Permissions

The most common anti-pattern is using wildcards for actions and resources:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "*",
      "Resource": "*"
    }
  ]
}

This is the AdministratorAccess policy. It grants full access to everything in the account. While convenient for development, it means a compromised credential can do anything, from deleting production databases to creating Bitcoin mining instances.

Even scoping to a single service with wildcards is risky:

{
  "Effect": "Allow",
  "Action": "s3:*",
  "Resource": "*"
}

This allows deleting any bucket, modifying bucket policies, and disabling encryption. An application that only reads files from a specific bucket does not need any of those permissions.

Writing Least Privilege Policies Step by Step

Start by identifying exactly what your application does. For a Lambda function that reads from one S3 bucket and writes to one DynamoDB table, the policy should reflect precisely that:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadFromInputBucket",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-input-bucket",
        "arn:aws:s3:::my-input-bucket/*"
      ]
    },
    {
      "Sid": "WriteToDynamoDB",
      "Effect": "Allow",
      "Action": [
        "dynamodb:PutItem",
        "dynamodb:UpdateItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/my-table"
    },
    {
      "Sid": "WriteLogsToCloudWatch",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*"
    }
  ]
}

Notice several important details:

  • Each statement has a descriptive Sid explaining its purpose
  • Actions are listed individually, not with wildcards
  • Resources use full ARNs, not *
  • S3 bucket access requires two resource ARNs: one for the bucket itself (for ListBucket) and one with /* for objects (for GetObject)

Using Conditions for Fine-Grained Control

Conditions add powerful constraints that go beyond actions and resources. They let you restrict access based on request context, tags, encryption status, and more.

Restrict by Source IP

Lock down console access to your office IP range:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "NotIpAddress": {
          "aws:SourceIp": [
            "203.0.113.0/24",
            "198.51.100.0/24"
          ]
        },
        "Bool": {
          "aws:ViaAWSService": "false"
        }
      }
    }
  ]
}

The aws:ViaAWSService condition is critical. Without it, AWS services calling other services on your behalf (like CloudFormation creating resources) would also be denied.

Require Encryption

Ensure objects uploaded to S3 are always encrypted:

{
  "Effect": "Deny",
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::sensitive-bucket/*",
  "Condition": {
    "StringNotEquals": {
      "s3:x-amz-server-side-encryption": "aws:kms"
    }
  }
}

Restrict by Tags

Allow users to manage only EC2 instances they created:

{
  "Effect": "Allow",
  "Action": [
    "ec2:StartInstances",
    "ec2:StopInstances",
    "ec2:RebootInstances"
  ],
  "Resource": "arn:aws:ec2:*:*:instance/*",
  "Condition": {
    "StringEquals": {
      "ec2:ResourceTag/Owner": "${aws:username}"
    }
  }
}

The ${aws:username} variable dynamically resolves to the IAM user making the request, so each user can only manage instances tagged with their username.

Restrict by Time

Grant temporary access that expires automatically:

{
  "Effect": "Allow",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::audit-bucket/*",
  "Condition": {
    "DateLessThan": {
      "aws:CurrentTime": "2026-08-01T00:00:00Z"
    }
  }
}

Permission Boundaries: Guardrails for Delegated Administration

Permission boundaries set the maximum permissions that an IAM entity can have. They do not grant permissions on their own but limit what identity-based policies can grant.

This is especially useful when allowing developers to create their own IAM roles (for Lambda functions, for example) without risking privilege escalation:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowedServices",
      "Effect": "Allow",
      "Action": [
        "s3:*",
        "dynamodb:*",
        "sqs:*",
        "sns:*",
        "logs:*",
        "lambda:*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenySecurityServices",
      "Effect": "Deny",
      "Action": [
        "iam:*",
        "organizations:*",
        "cloudtrail:DeleteTrail",
        "cloudtrail:StopLogging"
      ],
      "Resource": "*"
    }
  ]
}

Attach this as a permission boundary when creating roles:

aws iam create-role \
  --role-name my-lambda-role \
  --assume-role-policy-document file://trust-policy.json \
  --permissions-boundary arn:aws:iam::123456789012:policy/DeveloperBoundary

Even if someone attaches AdministratorAccess to this role, the boundary prevents it from accessing IAM or disabling CloudTrail.

Using IAM Access Analyzer to Right-Size Permissions

IAM Access Analyzer can generate policies based on actual API activity recorded in CloudTrail. This is the most reliable way to achieve least privilege for existing workloads.

Step 1: Enable CloudTrail

Ensure CloudTrail is logging API calls for at least 30 days to capture normal usage patterns.

Step 2: Generate a Policy

# Start policy generation for a role
aws accessanalyzer start-policy-generation \
  --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/management-trail",
        "allRegions": true
      }
    ],
    "accessRole": "arn:aws:iam::123456789012:role/AccessAnalyzerRole",
    "startTime": "2026-06-01T00:00:00Z",
    "endTime": "2026-07-01T00:00:00Z"
  }'

Step 3: Review and Apply

The generated policy reflects only the actions and resources the role actually used during the analysis period. Review it carefully, add any actions needed for rare but legitimate operations, and replace the existing overly broad policy.

Service Control Policies for Organization-Wide Guardrails

If you use AWS Organizations, Service Control Policies (SCPs) set hard permission boundaries across all accounts in an organizational unit:

{
  "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": "ProtectCloudTrail",
      "Effect": "Deny",
      "Action": [
        "cloudtrail:DeleteTrail",
        "cloudtrail:StopLogging",
        "cloudtrail:UpdateTrail"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyRootUser",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringLike": {
          "aws:PrincipalArn": "arn:aws:iam::*:root"
        }
      }
    }
  ]
}

This SCP prevents anyone in the organization from launching resources in unapproved regions, tampering with CloudTrail, or using the root user.

Common IAM Mistakes to Avoid

Using long-lived access keys. Prefer IAM roles with temporary credentials. If you must use access keys, rotate them regularly and never embed them in code.

Attaching policies directly to users. Use IAM groups or roles instead. This makes permission management scalable and auditable.

Not using MFA for privileged operations. Add MFA conditions to sensitive actions:

{
  "Effect": "Deny",
  "Action": [
    "ec2:TerminateInstances",
    "rds:DeleteDBInstance"
  ],
  "Resource": "*",
  "Condition": {
    "BoolIfExists": {
      "aws:MultiFactorAuthPresent": "false"
    }
  }
}

Ignoring unused permissions. Review IAM credentials reports monthly. Remove users and roles that have not been used in 90 days.

Not separating environments. Use separate AWS accounts for dev, staging, and production. This provides the strongest isolation boundary.

A Practical Audit Checklist

Run through this checklist quarterly:

# Find users with console access but no MFA
aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | base64 -d | \
  awk -F',' '$4=="true" && $8=="false" {print $1}'

# Find access keys older than 90 days
aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | base64 -d | \
  awk -F',' '$9=="true" && $10!="N/A" {print $1, $10}'

# List policies with admin access
aws iam list-policies --scope Local --query 'Policies[].Arn' --output text | \
  xargs -I {} aws iam get-policy-version \
    --policy-arn {} \
    --version-id $(aws iam get-policy --policy-arn {} --query 'Policy.DefaultVersionId' --output text) \
    --query 'PolicyVersion.Document'

# Check for inline policies (should be avoided)
aws iam list-users --query 'Users[].UserName' --output text | \
  xargs -I {} sh -c 'echo "User: {}"; aws iam list-user-policies --user-name {}'

Wrapping Up

Least privilege is not a one-time exercise but an ongoing practice. Start by replacing wildcard policies with specific actions and resources. Add conditions to restrict access by IP, tags, encryption, and time. Use IAM Access Analyzer to generate policies based on actual usage. And implement permission boundaries and SCPs to create guardrails that prevent privilege escalation.

The effort you put into IAM policies directly reduces your blast radius when (not if) credentials are compromised. Every wildcard you eliminate is one less thing an attacker can exploit.