Skip to content
C Codeloom
DevOps

AWS IAM Roles and Policies: A Practical Tutorial

Learn how AWS IAM roles, trust policies, and permissions policies work together. Build a small role from scratch and assume it from another account.

·5 min read · By Codeloom
Beginner 11 min read

What you'll learn

  • The difference between users, roles, and policies
  • How a trust policy controls who can assume a role
  • How a permissions policy controls what the role can do
  • How to assume a role with the AWS CLI
  • How to avoid the most common IAM mistakes

Prerequisites

  • Comfortable with the Linux command line

IAM is the part of AWS that decides who can do what. Get it right and your infrastructure has clean, auditable boundaries. Get it wrong and you either lock yourself out of your own account or hand the keys to anyone with a stolen access key. The most important concept to internalize is the role — and the two policies attached to it.

Users versus roles

An IAM user is a long-lived identity with a password or access keys. A role is an identity that nobody owns directly; it is assumed temporarily by a user, a service, or another account. When the role is assumed, AWS issues short-lived credentials that expire in an hour or so by default.

Roles are the modern default. Almost every recommendation now boils down to “stop creating IAM users; use roles.” Users still exist for break-glass admin access and legacy integrations, but for applications, CI/CD, and cross-account access, roles win.

The two policies on every role

A role has two policy documents attached to it.

  • A trust policy answers “who is allowed to assume this role?”
  • One or more permissions policies answer “once assumed, what can the role do?”

Both are JSON, both use the same Statement syntax, and confusing them is the single most common IAM mistake.

A minimal trust policy

Here is a trust policy that lets an EC2 instance assume the role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}

Principal is the identity being authorized. sts:AssumeRole is the action being granted. For a role assumed by another account, the principal would be that account’s ARN. For a role assumed by GitHub Actions over OIDC, the principal is the GitHub OIDC provider.

A minimal permissions policy

Now the permissions policy — what the role can do once assumed.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::my-app-bucket/*"
    }
  ]
}

This grants read and write on objects in one bucket, nothing else. Scope your permissions tightly — the wildcard * on actions or resources is rarely justified outside of an admin role.

Creating the role with the CLI

You can do all of this in the console, but the CLI is faster once you know the shape.

aws iam create-role \
  --role-name app-s3-writer \
  --assume-role-policy-document file://trust.json

aws iam put-role-policy \
  --role-name app-s3-writer \
  --policy-name s3-bucket-rw \
  --policy-document file://permissions.json

Two files, two commands, one role. To attach it to an EC2 instance you wrap it in an instance profile and pass the profile name when launching the instance.

Assuming a role from the CLI

To assume a role manually, call sts assume-role and export the temporary credentials.

creds=$(aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/app-s3-writer \
  --role-session-name local-test)

export AWS_ACCESS_KEY_ID=$(echo "$creds" | jq -r .Credentials.AccessKeyId)
export AWS_SECRET_ACCESS_KEY=$(echo "$creds" | jq -r .Credentials.SecretAccessKey)
export AWS_SESSION_TOKEN=$(echo "$creds" | jq -r .Credentials.SessionToken)

aws s3 ls s3://my-app-bucket

In practice you rarely script this by hand. The AWS CLI supports named profiles with role_arn and source_profile in ~/.aws/config, and most SDKs assume roles automatically when configured with the right environment variables.

Cross-account roles

A frequent pattern: a CI account assumes a role in a production account to deploy. The trust policy in the prod account names the CI account as principal, and often adds an ExternalId condition to defeat the confused deputy problem.

{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::111111111111:root" },
  "Action": "sts:AssumeRole",
  "Condition": {
    "StringEquals": { "sts:ExternalId": "shared-secret-string" }
  }
}

The CI tool passes that ExternalId when assuming the role. Without it, the request fails.

Common mistakes

A few patterns will save you real pain:

  • No wildcards on production roles. "Action": "*" and "Resource": "*" belong in throwaway sandbox roles, not in anything customer-facing.
  • Do not embed AWS access keys in code or images. Use instance profiles, ECS task roles, or OIDC. Static keys are the leading cause of cloud breaches.
  • Watch trust policy drift. When a role’s trust policy grows new principals over time, audit it. Old contractor accounts should not still be in there.
  • Use IAM Access Analyzer. It will tell you which roles have never been used and which permissions are unused, which is half the work of tightening policies.

A useful mental model

Treat each role like a job description. Decide who is hired into the job (trust policy) and what tasks the job is allowed to perform (permissions policy). If a new task comes along, write a new role rather than expanding an existing one. That single discipline keeps an account audit-ready as it grows.