IAM Roles in Terraform: Trust Policies, Inline Policies, and Common Patterns
Creating IAM roles in Terraform requires understanding four separate resources: the trust policy (who can assume), the role itself, the permission policy (what it can do), and the attachment linking them. Getting the trust policy wrong means the role exists but can't be assumed.
The four Terraform resources for an IAM role
Creating a functional IAM role in Terraform requires composing four resources:
# 1. Trust policy: who can assume this role
data "aws_iam_policy_document" "assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
# 2. The role itself
resource "aws_iam_role" "lambda_exec" {
name = "lambda-execution-role"
assume_role_policy = data.aws_iam_policy_document.assume_role.json
}
# 3. Permission policy: what the role can do
data "aws_iam_policy_document" "lambda_permissions" {
statement {
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = ["arn:aws:logs:*:*:*"]
}
statement {
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::my-bucket/*"]
}
}
resource "aws_iam_policy" "lambda_permissions" {
name = "lambda-execution-permissions"
policy = data.aws_iam_policy_document.lambda_permissions.json
}
# 4. Attachment: link the policy to the role
resource "aws_iam_role_policy_attachment" "lambda_permissions" {
role = aws_iam_role.lambda_exec.name
policy_arn = aws_iam_policy.lambda_permissions.arn
}
aws_iam_policy_document vs raw JSON
PatternTerraform / AWS IAMaws_iam_policy_document is a data source that generates IAM policy JSON. It supports policy merging, variable interpolation, and produces validated JSON. Use it instead of jsonencode() or raw JSON strings — it provides better readability and catches structural errors at plan time.
Prerequisites
- Terraform data sources
- IAM policy JSON structure
- STS AssumeRole
Key Points
- aws_iam_policy_document generates the policy JSON — reference its .json attribute.
- Multiple statements in one document: add multiple statement {} blocks.
- Override policies: use source_policy_documents and override_policy_documents to merge or modify policies.
- aws_iam_role_policy (inline) vs aws_iam_role_policy_attachment (managed) — different resources with different lifecycle behaviors.
Common trust policy patterns
Lambda:
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
ECS tasks:
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
EC2 instance profiles:
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
Cross-account assumption (allowing another account's role to assume):
principals {
type = "AWS"
identifiers = ["arn:aws:iam::987654321098:root"] # entire account
# or specific role: "arn:aws:iam::987654321098:role/SpecificRole"
}
EKS IRSA (OIDC-based service accounts):
data "aws_iam_openid_connect_provider" "eks" {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
data "aws_iam_policy_document" "irsa_trust" {
statement {
effect = "Allow"
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.eks.arn]
}
actions = ["sts:AssumeRoleWithWebIdentity"]
condition {
test = "StringEquals"
variable = "${data.aws_iam_openid_connect_provider.eks.url}:sub"
values = ["system:serviceaccount:default:my-service-account"]
}
}
}
Inline vs managed policies: lifecycle differences
Managed policy (aws_iam_policy + aws_iam_role_policy_attachment):
- The policy exists as a standalone resource with its own ARN
- Can be attached to multiple roles
- Deletion requires detaching from all roles first (or
force_detach_policies = true) - Terraform tracks attachments separately from the policy
Inline policy (aws_iam_role_policy):
- The policy is embedded in the role, deleted when the role is deleted
- Cannot be reused across roles
- Simpler Terraform when the policy is tightly coupled to one role
# Inline policy (simpler for role-specific permissions)
resource "aws_iam_role_policy" "lambda_s3" {
name = "s3-read"
role = aws_iam_role.lambda_exec.id
policy = data.aws_iam_policy_document.lambda_permissions.json
}
Use inline policies when the permissions are conceptually part of the role's identity. Use managed policies when the same permission set needs to apply to multiple roles (e.g., a read-only S3 policy shared across many Lambda functions).
💡Attaching AWS managed policies
AWS provides managed policies for common use cases. Attach them directly instead of duplicating policy JSON:
# Attach AWS managed policies
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy_attachment" "lambda_vpc" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
Common managed policy ARNs:
AWSLambdaBasicExecutionRole: CloudWatch Logs write accessAWSLambdaVPCAccessExecutionRole: VPC + CloudWatch LogsAmazonEKSWorkerNodePolicy: EC2 EKS node permissionsAmazonEC2ContainerRegistryReadOnly: ECR pull permissionsAmazonECSTaskExecutionRolePolicy: ECS task execution (ECR + CloudWatch)
Risk: AWS updates managed policies over time. If a new action is added to a managed policy, all roles using it get the permission automatically. For security-sensitive roles (those with S3 write or IAM permissions), prefer customer-managed policies that you explicitly control.
EC2 instance profiles: the extra step
EC2 instances don't directly use IAM roles — they use instance profiles, which are a wrapper around an IAM role. Instance profiles are created automatically in the console but require explicit Terraform resources:
resource "aws_iam_role" "ec2_role" {
name = "ec2-instance-role"
assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
}
resource "aws_iam_instance_profile" "ec2" {
name = "ec2-instance-profile"
role = aws_iam_role.ec2_role.name
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.medium"
iam_instance_profile = aws_iam_instance_profile.ec2.name
}
Forgetting aws_iam_instance_profile and passing the role ARN directly to the instance is a common error — iam_instance_profile expects the profile name, not the role ARN or name.
A Terraform apply creates an IAM role with a permission policy that allows s3:GetObject on a specific bucket. When the Lambda function runs, it receives AccessDenied on s3:GetObject. The Terraform state shows the policy is attached. What should you check first?
easyThe Lambda function's execution role ARN is correctly configured in the Lambda function resource. The permission policy is attached and allows s3:GetObject. The bucket is in the same account.
AThe Lambda function needs to be redeployed for the IAM changes to take effect
Incorrect.Lambda picks up IAM role changes on the next invocation — no redeployment needed. IAM policy changes take effect within seconds.BThe trust policy may not allow lambda.amazonaws.com to assume the role — without a trust policy permitting Lambda, the function can't use the role at all
Correct!A role exists only if both the trust policy (who can assume) and permission policy (what they can do) are correctly configured. If the trust policy doesn't include lambda.amazonaws.com as a Principal with sts:AssumeRole, Lambda can't assume the role and all operations fail with AccessDenied. Check aws_iam_role.assume_role_policy — it must explicitly allow the Lambda service.CS3 bucket policies block all Lambda function access by default
Incorrect.S3 buckets have no default bucket policy. Same-account access via IAM roles doesn't require a bucket policy.DThe permission policy uses the wrong resource ARN format
Incorrect.This is worth checking, but the first thing to verify is whether the role can be assumed at all. ARN format errors produce a different error message pattern.
Hint:Two things must be correct for a role to work: the trust policy (can assume) and the permission policy (can do). Which might be wrong here?