AWS IAM with Terraform: Roles, Policies, and Examples

Managing AWS IAM with Terraform lets you define roles, policies, and attachments as version-controlled code. Every permission change goes through a pull request, gets reviewed by your team, and applies consistently across accounts. This page walks you through the key resources, real examples, and the decisions you need to make along the way.

Simple explanation

Managing IAM with Terraform means you write text files that describe your AWS permissions, and Terraform turns those files into real IAM roles and policies in your account. Instead of clicking through the console or running CLI commands, you declare what you want and let Terraform figure out what needs to change.

Three concepts come up constantly:

  • Trust policy answers “who is allowed to assume this role?” It might be a Lambda function, an EC2 instance, or another AWS account.
  • Permissions policy answers “once someone has assumed the role, what can they do?” This is where you list specific actions like s3:GetObject or dynamodb:PutItem.
  • Attachment is the link between a role and a permissions policy. A role can have multiple policies attached, and a single policy can be attached to multiple roles.
Analogy: office building security

Think of IAM like the security system in an office building. A trust policy is the list of people allowed through the front door. A permissions policy is the keycard that decides which floors and rooms they can enter. An attachment is the act of handing that keycard to a specific person. Terraform lets you write all three in a file, review changes before they take effect, and apply the same setup to every building you manage.

If you are new to these concepts, read IAM Roles Explained and IAM Policy Structure first. This page assumes you understand what roles and policies are and focuses on how to manage them with Terraform.

Why use Terraform for IAM

IAM changes made by hand in the console are invisible to the rest of your team. Nobody reviewed the decision, nobody knows why the role has those permissions, and six months later nobody remembers who created it.

Terraform solves this by turning every IAM change into a code change:

  • Reviewability. Every permission change is a pull request with a diff. Your team sees exactly what changed and why.
  • Repeatability. Running terraform apply in a new account produces the same roles and policies every time. No manual steps to forget.
  • Drift detection. If someone changes a role in the console, terraform plan shows the difference so you can revert it or update your code.
  • Multi-environment consistency. Dev, staging, and production get the same IAM structure with environment-specific values injected through variables.

For a broader view of this approach, see Infrastructure as Code in AWS.

When to use this

Terraform-managed IAM works well when:

  • Your team uses pull requests and wants IAM changes reviewed like application code.
  • You manage multiple AWS accounts or environments and need consistent roles across them.
  • Platform or security teams own IAM definitions and application teams consume them.
  • Your CI/CD pipelines need roles for deployment, and those roles should be defined alongside the infrastructure.
  • You need to track who changed what permission and when, beyond what CloudTrail alone provides.
Not sure where to start?

If you are completely new to Terraform, start with Terraform for AWS: Getting Started before tackling IAM. If you are new to IAM itself, read IAM Roles Explained first. This page assumes a working knowledge of both.

How it works

The workflow for creating a Terraform-managed IAM role follows a consistent pattern:

  1. Define who can assume the role. Write a trust policy that names the AWS service, account, or identity provider that should be allowed to use this role.
  2. Define what the role can do. Write a permissions policy that lists the specific API actions and resources the role needs. Follow the principle of least privilege and grant only what is required.
  3. Create the role. Use aws_iam_role and attach the trust policy.
  4. Create and attach the permissions policy. Use aws_iam_policy and aws_iam_role_policy_attachment to link the policy to the role.
  5. Apply with Terraform. Run terraform plan to review, then terraform apply to create the resources in AWS.
  6. Verify and maintain. Check the role in the console or with the AWS CLI, then keep all future changes in your Terraform code.

Key Terraform resources for AWS IAM

Most IAM work in Terraform uses a small set of resources:

aws_iam_role

Creates a role with a trust policy. The assume_role_policy argument defines who can assume the role. This is the starting point for any IAM role.

aws_iam_policy

Creates a standalone, reusable customer managed policy. You define the permissions once and attach the policy to one or more roles. This is the right choice when multiple roles need the same permissions or when you want policy versioning.

aws_iam_role_policy_attachment

Links a policy (either AWS managed or customer managed) to a role. Each attachment is a separate resource, so Terraform can add or remove individual policies without affecting others on the same role.

aws_iam_instance_profile

Wraps a role for use with EC2 instances. EC2 does not accept a role directly and requires an instance profile as a wrapper. Other services like Lambda and ECS do not need this.

aws_iam_role_policy

Creates an inline policy embedded directly on a role. Inline policies are deleted when the role is deleted, which makes them useful for tightly coupled, single-role permissions. For most cases, prefer customer managed policies. See AWS Managed vs Customer Managed Policies for guidance on when each type makes sense.

aws_iam_policy_document

A data source that generates valid IAM policy JSON from structured HCL. Use it instead of writing raw JSON strings. You get syntax checking, variable interpolation, and cleaner diffs in pull requests.

data "aws_iam_policy_document" "example_trust" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

Access the output as data.aws_iam_policy_document.example_trust.json. This returns a JSON string you pass to other resources.

Full example: Lambda execution role

This example creates a Lambda execution role that can read and write to a specific DynamoDB table and write logs to CloudWatch. Every resource and data source it references is defined here.

# ------------------------------------
# Data sources
# ------------------------------------

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# ------------------------------------
# DynamoDB table (the resource this Lambda accesses)
# ------------------------------------

resource "aws_dynamodb_table" "orders" {
  name         = "orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "order_id"

  attribute {
    name = "order_id"
    type = "S"
  }

  tags = {
    Environment = var.environment
    Team        = "platform"
  }
}

# ------------------------------------
# Trust policy: who can assume this role
# ------------------------------------

data "aws_iam_policy_document" "lambda_trust" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

# ------------------------------------
# Permissions policy: what the role can do
# ------------------------------------

data "aws_iam_policy_document" "order_processor_permissions" {
  statement {
    sid    = "DynamoDBOrdersAccess"
    effect = "Allow"
    actions = [
      "dynamodb:GetItem",
      "dynamodb:PutItem",
      "dynamodb:UpdateItem",
      "dynamodb:Query"
    ]
    resources = [
      aws_dynamodb_table.orders.arn,
      "${aws_dynamodb_table.orders.arn}/index/*"
    ]
  }

  statement {
    sid    = "CloudWatchLogsWrite"
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = [
      "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/order-processor:*"
    ]
  }
}

# ------------------------------------
# IAM role
# ------------------------------------

resource "aws_iam_role" "order_processor" {
  name               = "order-processor-lambda-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_trust.json
  description        = "Execution role for the order-processor Lambda function"

  tags = {
    Environment = var.environment
    Team        = "platform"
  }
}

# ------------------------------------
# Customer managed policy + attachment
# ------------------------------------

resource "aws_iam_policy" "order_processor" {
  name        = "order-processor-lambda-policy"
  description = "Permissions for the order-processor Lambda function"
  policy      = data.aws_iam_policy_document.order_processor_permissions.json
}

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

# ------------------------------------
# Variables
# ------------------------------------

variable "environment" {
  description = "Deployment environment (e.g. dev, staging, production)"
  type        = string
  default     = "production"
}

This example grants CloudWatch Logs permissions scoped to the specific Lambda log group rather than using the broad AWSLambdaBasicExecutionRole managed policy. Scoped permissions follow the principle of least privilege more closely. The role can only write to its own log group, not to every log group in the account.

Note

An alternative approach is to attach AWSLambdaBasicExecutionRole and skip the custom CloudWatch Logs statement. That managed policy grants logs:CreateLogGroup, logs:CreateLogStream, and logs:PutLogEvents on all log groups. It is simpler but less restrictive. Choose based on your team’s security requirements.

EC2 instance profile example

EC2 instances cannot use IAM roles directly. AWS requires a wrapper called an instance profile that holds the role. When you assign a role to an EC2 instance in the console, AWS creates the instance profile behind the scenes. In Terraform, you create it explicitly.

Analogy: the badge holder

Think of the IAM role as an employee badge and the instance profile as the lanyard that holds it. The EC2 instance (the person) cannot clip the badge directly to their shirt. They need the lanyard first. The badge still controls what doors open, but without the lanyard, the instance has no way to carry it.

The instance profile is what gets attached to the instance. The instance then uses the role inside the profile to request temporary credentials from the instance metadata service. For more on how this credential exchange works, see Role Assumption Explained.

# Trust policy: allows EC2 service to assume this role
data "aws_iam_policy_document" "ec2_trust" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

# IAM role for the EC2 application
resource "aws_iam_role" "ec2_app" {
  name               = "ec2-app-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_trust.json

  tags = {
    Environment = "production"
    Team        = "platform"
  }
}

# Instance profile: required wrapper for EC2
resource "aws_iam_instance_profile" "ec2_app" {
  name = "ec2-app-profile"
  role = aws_iam_role.ec2_app.name
}

# EC2 instance using the profile
resource "aws_instance" "app_server" {
  ami                  = "ami-0abcdef1234567890"
  instance_type        = "t3.micro"
  iam_instance_profile = aws_iam_instance_profile.ec2_app.name

  tags = {
    Name = "app-server"
  }
}

You then attach permissions policies to aws_iam_role.ec2_app exactly the same way as the Lambda example above. The instance profile is just the delivery mechanism.

Terraform vs AWS CLI for IAM

Terraform and the AWS CLI serve different purposes when managing IAM. They complement each other rather than compete.

When Terraform is the right tool

  • Creating, updating, or deleting roles and policies that should be reviewed and version-controlled.
  • Reproducing the same IAM setup across multiple accounts or environments.
  • Ensuring that IAM configuration stays consistent over time through drift detection.
  • Managing IAM alongside the infrastructure that depends on it (Lambda functions, EC2 instances, ECS tasks).

When the CLI is more useful

  • Inspecting existing roles and policies during debugging.
  • Running quick checks like “what policies are attached to this role?” or “can this role access this resource?”
  • One-off operations like simulating a policy or testing a trust relationship.
  • Emergencies where you need to revoke access immediately and cannot wait for a Terraform pipeline.
How to think about it

Terraform is the architect’s blueprint. The CLI is the flashlight you carry during a site inspection. You would not build a house with a flashlight, and you would not inspect a dark room with a blueprint. Use both for what they are good at.

For CLI-based IAM management, see Managing IAM with the AWS CLI.

State, security, and drift considerations

Terraform state tracks every IAM resource it manages. IAM state carries specific risks you should understand before putting roles and policies into Terraform.

Sensitive values in state

If you create access keys with aws_iam_access_key, the secret key is stored in Terraform state in plaintext. Use a remote backend with encryption (S3 with SSE-KMS is common) and restrict access to state files. Better still, avoid creating access keys with Terraform entirely and use roles and temporary credentials instead. See Why Long-Lived Access Keys Are Dangerous for background on this risk.

Warning

Terraform state files can contain secrets in plaintext, including access keys and secret keys. Never commit state to Git. Always use a remote backend with encryption enabled, and restrict who can read state files to your infrastructure and CI/CD teams.

Remote state and encryption

Store state in a remote backend (S3 + DynamoDB locking is the standard AWS pattern). Enable server-side encryption and restrict bucket access to your CI/CD pipeline and infrastructure team. Never commit state files to Git. For a full walkthrough, see Terraform State Management in AWS.

Drift from manual console changes

If someone edits a Terraform-managed role in the console, the next terraform plan shows the difference. Terraform reverts the change on the next apply. This is useful for catching unauthorized changes, but it also means your team needs discipline. If Terraform owns a resource, all changes go through Terraform.

Importing existing IAM resources

If you have roles created manually and you want to bring them under Terraform management:

# Import an existing role
terraform import aws_iam_role.my_role my-existing-role-name

# Import an existing policy by ARN
terraform import aws_iam_policy.my_policy arn:aws:iam::123456789012:policy/MyPolicy
After importing

Always run terraform plan before terraform apply. Terraform may show a diff if the imported resource differs from your HCL. Review carefully, because Terraform will change the real resource to match your code.

Rename and replacement risks

Changing the name of an aws_iam_role or aws_iam_policy forces Terraform to destroy and recreate the resource. Any Lambda function, EC2 instance, or other service using the old role loses its permissions during the apply. Use name_prefix with create_before_destroy to avoid downtime:

resource "aws_iam_role" "app" {
  name_prefix        = "my-app-role-"
  assume_role_policy = data.aws_iam_policy_document.trust.json

  lifecycle {
    create_before_destroy = true
  }
}
Rename trap

Renaming an IAM role in Terraform causes a delete-then-create cycle. During the gap between deletion and recreation, every service using that role loses access. This can bring down production workloads. Always use name_prefix with create_before_destroy if there is any chance the name will change.

Provider authentication

The credentials Terraform uses to create IAM resources matter. In CI/CD, use OIDC federation or an instance profile so your pipeline never handles long-lived access keys. Locally, use aws sso login or short-lived credentials from STS. Never hardcode access keys in your Terraform files or provider blocks.

Common mistakes

  1. Hardcoded account IDs and ARNs. Use data.aws_caller_identity.current.account_id and resource references instead of pasting account IDs. Hardcoded values break when you apply the same code in a different account.
  2. Forgetting the .json attribute. The aws_iam_policy_document data source output must be accessed as .json. Omitting it causes a type error that is not always obvious.
  3. Duplicate policy attachments. Attaching the same policy ARN to a role twice via two separate aws_iam_role_policy_attachment resources causes Terraform to fail on the second apply. Use one attachment per policy-role pair.
  4. Mixing manual and Terraform ownership. If you edit a Terraform-managed role in the console, Terraform reverts it on the next apply. Pick one tool per resource and stick with it.
  5. Overly broad permissions. Using "Resource": "*" or attaching AdministratorAccess to get things working quickly creates security risk that is hard to walk back. Start narrow and expand only when you hit a real permission error. See Terraform Permission Errors for debugging help.
  6. Unclear trust relationships. A trust policy that allows too many principals to assume a role is just as dangerous as overly broad permissions. Be explicit about which services and accounts can assume each role.
  7. Examples that reference undefined resources. If your Terraform references data.aws_caller_identity.current or a DynamoDB table, those must be defined somewhere. Undefined references cause errors on terraform plan.

Frequently asked questions

When should I use Terraform instead of the AWS CLI for IAM?

Use Terraform when IAM changes need to be reviewed, versioned, and applied consistently across environments. Use the CLI for one-off inspections, debugging, or quick checks on existing roles and policies.

What happens if I change a Terraform-managed IAM role in the console?

Terraform detects the difference on the next plan and reverts the resource to match your HCL code on the next apply. This is called drift. Avoid editing Terraform-managed resources outside of Terraform.

How do I import an existing IAM role into Terraform?

Run terraform import aws_iam_role.your_name your-role-name to bring the role into state. Then write matching HCL and run terraform plan to verify there are no unexpected diffs before applying.

Should I use inline policies or customer managed policies?

Customer managed policies are better in most cases because they can be reused across multiple roles and have their own versioning. Inline policies are useful when a policy is tightly bound to a single role and should be deleted when the role is deleted.

How should Terraform authenticate to AWS securely?

Use IAM roles instead of long-lived access keys. In CI/CD, use OIDC federation or an instance profile. Locally, use aws sso login or short-lived credentials from STS. Never hardcode access keys in Terraform files.

Last verified: 30 March 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.