Home β€Ί AWS Resources β€Ί AWS IAM

AWS IAM

Identity and Access Management on AWS: least-privilege patterns, roles vs policies, cross-account access, and common mistakes.

What Is IAM?

IAM (Identity and Access Management) controls who can do what on AWS. Every API call is authorized by IAM. Whether it's a human in the console, a CLI script, a Lambda function, or an EC2 instance.

IAM is not your application's authorization system (that's a different problem. See my blog post on this). IAM controls access to AWS resources: who can create an S3 bucket, who can invoke a Lambda function, who can read from a DynamoDB table.

Core Concepts

Principals

Who is making the request:

  • IAM Users: Long-lived credentials (access key + secret). Use for human access only when SSO isn't available.
  • IAM Roles: Temporary credentials assumed by services, applications, or federated users. The default for everything in production.
  • AWS Services: Lambda, ECS, EC2. They assume roles to call other AWS services.

Policies

What actions are allowed/denied on which resources:

  • Identity-based policies: Attached to users, groups, or roles
  • Resource-based policies: Attached to the resource (S3 bucket policy, SQS queue policy, KMS key policy)
  • Permission boundaries: Maximum permissions an identity can have (caps what identity policies can grant)
  • Service Control Policies (SCPs): Organization-wide guardrails

Evaluation logic

AWS evaluates policies in this order:

  1. SCPs (org-level). If denied here, stopped
  2. Resource-based policy. Can grant cross-account access
  3. Permission boundaries. Caps identity permissions
  4. Identity-based policies. What the caller is explicitly allowed to do
  5. Session policies (for assumed roles)

Default deny. Unless something explicitly allows an action, it's denied. An explicit deny anywhere in the chain overrides any allow.

Least-Privilege Patterns

For Lambda functions

Each Lambda function gets its own execution role with only the permissions it needs:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:Query"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/orders"
    },
    {
      "Effect": "Allow",
      "Action": ["sqs:SendMessage"],
      "Resource": "arn:aws:sqs:us-east-1:123456789012:notifications"
    }
  ]
}

Not dynamodb:*. Not Resource: "*". Specific actions on specific resources.

For ECS tasks

Separate the task role (what the application can do) from the task execution role (what ECS needs to pull images and write logs):

  • Task role: access to DynamoDB, S3, SQS. Your application's permissions
  • Execution role: access to ECR (pull images), CloudWatch Logs (write logs), Secrets Manager (inject secrets)

Using CDK's grant methods

CDK makes least-privilege easy:

// This creates a policy with exactly the actions needed
table.grantReadData(lambdaFunction);        // GetItem, Query, Scan, BatchGetItem
queue.grantSendMessages(lambdaFunction);    // SendMessage, SendMessageBatch
bucket.grantRead(lambdaFunction);           // GetObject, ListBucket
secret.grantRead(lambdaFunction);           // GetSecretValue

Each .grant* method creates a scoped policy. You never write raw JSON unless you need something custom.

Roles vs Users

Always prefer roles:

  • Roles provide temporary credentials (expire in 1-12 hours)
  • No long-lived secrets to leak or rotate
  • Can be assumed by services, other accounts, or federated users

Users are for:

  • CI/CD systems that can't assume roles (legacy)
  • Break-glass emergency access
  • Local development when SSO isn't configured

If you have IAM users with access keys in production workloads, that's a security finding.

Cross-Account Access

Role assumption

Account A creates a role with a trust policy allowing Account B to assume it:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::222222222222:root" },
    "Action": "sts:AssumeRole",
    "Condition": {
      "StringEquals": { "sts:ExternalId": "shared-secret-id" }
    }
  }]
}

Account B's workloads call sts:AssumeRole to get temporary credentials for Account A.

Resource-based policies

Some services (S3, SQS, SNS, KMS, Lambda) support resource-based policies that directly grant cross-account access without role assumption:

{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::222222222222:role/DataProcessor" },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::shared-data-bucket/*"
}

Common Mistakes

Wildcard resources

"Resource": "*"

This grants the action on ALL resources of that type in your account. Never do this in production unless the action genuinely applies globally (like logs:CreateLogGroup).

Overly broad actions

"Action": "s3:*"

Your Lambda that reads files doesn't need s3:DeleteBucket. Grant specific actions.

Shared roles across functions

One role for all Lambda functions means every function can access every resource. Create per-function roles (CDK does this by default).

Not using conditions

Conditions restrict when a policy applies:

"Condition": {
  "StringEquals": { "aws:RequestedRegion": "us-east-1" },
  "IpAddress": { "aws:SourceIp": "10.0.0.0/8" }
}

Service Control Policies (SCPs)

For multi-account organizations, SCPs set guardrails that no one (not even account admins) can bypass:

{
  "Effect": "Deny",
  "Action": [
    "ec2:RunInstances"
  ],
  "Resource": "*",
  "Condition": {
    "StringNotEquals": {
      "ec2:Region": ["us-east-1", "us-west-2"]
    }
  }
}

This denies launching EC2 instances outside allowed regions, regardless of what IAM policies exist in the account.

Further Reading

Looking for hands-on help? View my AWS architecture services β†’

Getting IAM right?

Drop me a message β€” I typically respond within one business day.