# AWS IAM Roles and Policies with Terraform and OpenTofu

> How to create IAM roles, policies, instance profiles and assume-role trust relationships with Terraform or OpenTofu, following least privilege, with AWS examples.

- Source: https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/
- Published: 2026-08-22
- Updated: 2026-08-22
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## How to create AWS IAM roles and policies with Terraform

[AWS IAM](https://www.itwonderlab.com/aws-iam/) decides who can do what in an AWS account. In Terraform you work with four objects: **policies** (what is allowed), **roles** (an identity that someone or something can assume), **trust policies** (who can assume the role) and **instance profiles** (how an EC2 instance uses a role). For users, see [IAM users with Terraform](https://www.itwonderlab.com/terraform-aws-iam-users/).

### The two policies of a role

Every role has:

1. A **trust policy** (*assume role policy*): which principal can assume the role (an EC2 instance, a Lambda function, another account, GitHub).
2. One or more **permission policies**: what the role can do once assumed.

### Build policies with aws_iam_policy_document

Writing JSON by hand is error-prone. The `aws_iam_policy_document` data source builds it in [HCL](https://www.itwonderlab.com/hcl/) and lets you use references:

```hcl title="iam.tf"
data "aws_iam_policy_document" "ec2_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "read_bucket" {
  statement {
    sid       = "ListBucket"
    actions   = ["s3:ListBucket"]
    resources = [aws_s3_bucket.assets.arn]
  }

  statement {
    sid       = "ReadObjects"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.assets.arn}/*"]
  }
}
```

### Role, policy and attachment

```hcl title="iam.tf"
resource "aws_iam_role" "app" {
  name               = "ditwl-pro-app-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
}

resource "aws_iam_policy" "read_bucket" {
  name   = "ditwl-pro-app-read-bucket"
  policy = data.aws_iam_policy_document.read_bucket.json
}

resource "aws_iam_role_policy_attachment" "read_bucket" {
  role       = aws_iam_role.app.name
  policy_arn = aws_iam_policy.read_bucket.arn
}

# AWS managed policy, for example to use Systems Manager Session Manager
resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.app.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
```

An alternative for permissions that belong to only one role is `aws_iam_role_policy`, an inline policy. Avoid `aws_iam_policy_attachment` (without `role_`), which takes exclusive ownership of the policy and can detach it from other identities.

### Instance profile for EC2

EC2 instances use roles through an instance profile:

```hcl title="ec2.tf"
resource "aws_iam_instance_profile" "app" {
  name = "ditwl-pro-app-profile"
  role = aws_iam_role.app.name
}

resource "aws_instance" "app" {
  ami                  = data.aws_ami.ubuntu.id
  instance_type        = "t3.micro"
  iam_instance_profile = aws_iam_instance_profile.app.name
}
```

The application on the instance now gets temporary credentials from the metadata service. No access keys are stored on disk. See [EC2 with Terraform](https://www.itwonderlab.com/aws-terraform-tutorial-aws-ec2/).

### Allow another account or a person to assume a role

```hcl title="cross-account.tf"
data "aws_iam_policy_document" "cross_account" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::111111111111:root"]
    }

    condition {
      test     = "Bool"
      variable = "aws:MultiFactorAuthPresent"
      values   = ["true"]
    }
  }
}
```

The same pattern is used to give a CI/CD pipeline access: [GitHub Actions with OIDC](https://www.itwonderlab.com/terraform-github-actions-aws-oidc/).

### Permission boundaries

A permission boundary is a policy that sets the **maximum** permissions of a role, whatever policies are attached. It is useful when you let developers create their own roles:

```hcl title="boundary.tf"
resource "aws_iam_role" "developer_created" {
  name                 = "app-role"
  assume_role_policy   = data.aws_iam_policy_document.ec2_assume.json
  permissions_boundary = aws_iam_policy.boundary.arn
}
```

### Least privilege checklist

- Name actions and resources explicitly. Avoid `"Action": "*"` and `"Resource": "*"`.
- Use conditions (source VPC, MFA, tags, `aws:PrincipalOrgID`).
- One role per application and per environment.
- Prefer roles to users and temporary to long-lived credentials.
- Review the findings of IAM Access Analyzer and the "last used" information.
- Scan the code with [Checkov or Trivy](https://www.itwonderlab.com/terraform-security-scanning-tflint-checkov-trivy/).

> [!WARNING]
> Never create IAM users with access keys through Terraform for applications: the secret ends up in the [state](https://www.itwonderlab.com/terraform-state/). Use roles.

Next: [S3 with Terraform](https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/).
