Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 033 / 100

IAM with Terraform in Terraform, IaC & Cloud Automation

Least-privilege access as code.

Expona Admin7 min read

IAM with Terraform in Terraform, IaC & Cloud Automation

Managing identity and access in the cloud is often where infrastructure code either shines or creates dangerous security blind spots. When you treat IAM as an afterthought—scattering permissions across hand-crafted policies and human-managed roles—you end up with sprawl, inconsistency, and compliance headaches. In this post, we'll walk through how to enforce least-privilege access as code using Terraform, ensuring that every permission grant is deliberate, reviewable, and auditable.

Why IAM matters in infrastructure as code

IAM is not just a security layer bolted onto your infrastructure. It's the enforcement point for who can do what, where, and when. When you codify IAM with Terraform, you gain three critical benefits:

  • Reproducibility: Your access policies are version-controlled and repeatable across environments.
  • Auditability: Every permission change is tracked in git history, tied to a pull request, and tied to a decision.
  • Consistency: You eliminate the "did we give this role the same permissions in staging?" problem.

The challenge is that IAM is also deceptively complex. A single wildcard permission in a policy document can blow a hole in your security posture. That's why we need discipline, tooling, and review processes built into our Terraform workflows.

Policy documents—aws_iam_policy_document data sources are readable and validated

Writing IAM policies by hand as JSON strings is error-prone and hard to review. You'll quickly end up with typos, missing conditions, or syntax errors that only surface when Terraform applies. The aws_iam_policy_document data source is Terraform's answer: it lets you write policies declaratively, with validation, and outputs valid JSON.

Here's the difference. Instead of embedding raw JSON:

hcl

resource "aws_iam_policy" "s3_access_bad" {
  name   = "s3-access"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "s3:*"
      Resource = "*"
    }]
  })
}

Use the data source with clear, structured syntax:

hcl

data "aws_iam_policy_document" "s3_access" {
  statement {
    actions = [
      "s3:GetObject",
      "s3:PutObject"
    ]
    resources = [
      "arn:aws:s3:::my-app-bucket/*"
    ]
  }

  statement {
    actions = [
      "s3:ListBucket"
    ]
    resources = [
      "arn:aws:s3:::my-app-bucket"
    ]
  }
}

resource "aws_iam_policy" "s3_access" {
  name   = "s3-access"
  policy = data.aws_iam_policy_document.s3_access.json
}

Notice the improvements:

  • Readable: You can scan the structure without parsing JSON.
  • Separated actions and resources: You're forced to be explicit about which actions apply to which resources.
  • Validated: Terraform catches syntax errors at plan time, not at apply time.
  • Composable: You can build policies from multiple data sources, making it easy to share common statements across roles.

In a real system, you might have a shared policy for reading secrets from Secrets Manager that many roles need. Define it once, reference it everywhere:

hcl

data "aws_iam_policy_document" "read_secrets" {
  statement {
    actions = [
      "secretsmanager:GetSecretValue"
    ]
    resources = [
      "arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:app/*"
    ]
  }
}

data "aws_iam_policy_document" "lambda_execution" {
  source_policy_documents = [
    data.aws_iam_policy_document.read_secrets.json,
    data.aws_iam_policy_document.cloudwatch_logs.json
  ]
}

Roles over users—services assume roles; humans use SSO

In AWS, there are two identity primitives: users and roles. Too many teams treat users as the default and roles as an afterthought. We recommend the opposite.

Users are for humans with long-term credentials. They should be rare. Most humans should authenticate via your identity provider (Okta, Azure AD, etc.) and use temporary credentials via SSO. The IAM users that exist should be for CI/CD, local testing, or legacy systems in controlled circumstances.

Roles are for everything else: EC2 instances, Lambda functions, containers, even cross-account access. A role has a trust policy that defines who or what can assume it, and a permission policy that defines what they can do once they do.

Here's a practical example. Your ECS tasks need to pull images from ECR and write logs to CloudWatch:

hcl

data "aws_iam_policy_document" "ecs_task_assume_role" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "ecs_task_role" {
  name               = "ecs-task-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume_role.json
}

data "aws_iam_policy_document" "ecs_task_permissions" {
  statement {
    actions = [
      "ecr:GetDownloadUrlForLayer",
      "ecr:BatchGetImage"
    ]
    resources = [
      aws_ecr_repository.app.arn
    ]
  }

  statement {
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = [
      "${aws_cloudwatch_log_group.ecs.arn}:*"
    ]
  }
}

resource "aws_iam_role_policy" "ecs_task" {
  name   = "ecs-task-policy"
  role   = aws_iam_role.ecs_task_role.id
  policy = data.aws_iam_policy_document.ecs_task_permissions.json
}

The trust policy (the assume role policy) is as important as the permission policy. It answers: "Who is allowed to use this role?" By being explicit, you prevent scenarios where any service in your account can assume a role it shouldn't.

Scope resources—specific ARNs instead of wildcards

One of the most common security mistakes is using * as a resource in an IAM policy. It's tempting because it's simple, but it means "all resources of this type, everywhere." That's rarely what you actually want.

Let's say your Lambda function needs to read from a DynamoDB table called orders. This is too broad:

hcl

statement {
  actions = [
    "dynamodb:GetItem",
    "dynamodb:Query"
  ]
  resources = ["*"]
}

The function can now query any DynamoDB table in your account. If there's a bug in your code, or a compromise, the blast radius is huge.

This is better:

hcl

resource "aws_dynamodb_table" "orders" {
  name           = "orders"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "order_id"
  attribute {
    name = "order_id"
    type = "S"
  }
}

data "aws_iam_policy_document" "lambda_dynamodb" {
  statement {
    actions = [
      "dynamodb:GetItem",
      "dynamodb:Query"
    ]
    resources = [
      aws_dynamodb_table.orders.arn
    ]
  }
}

By referencing the actual table ARN, you've constrained access to a single table. If in the future you need a second table, you explicitly add its ARN.

The same principle applies to S3 buckets, KMS keys, RDS databases, and anything else with an ARN. Be specific. Use Terraform variables and resource references to avoid hardcoding ARNs, so that if the resource name or account changes, your policies stay in sync.

One nuance: some actions like iam:ListUsers or ec2:DescribeInstances don't support resource-level permissions. In those cases, document why you're using * and consider whether the principal actually needs the permission.

Review in pull requests—IAM changes deserve careful review

IAM changes should never be a rubber-stamp approval. A pull request that adds permissions to a role is a security decision, not just a code change. Set up your Terraform workflow to make IAM changes visible and reviewable.

First, use terraform plan output to see exactly what's changing. With well-structured IAM policies, the diff is readable:

hcl

# Example from terraform plan output
# aws_iam_role_policy.ecs_task will be updated in-place
~ resource "aws_iam_role_policy" "ecs_task" {
    name   = "ecs-task-policy"
    role   = "ecs-task-role"
  ~ policy = jsonencode(
      ~ {
          ~ Statement = [
              ~ {
                  ~ Action = [
                      + "s3:PutObject"
                    ]
                    Effect   = "Allow"
                    Resource = "arn:aws:s3:::logs/*"
                  }
            ]
        }
    )
  }

When this lands in a PR, your teammates can review whether adding s3:PutObject to that resource is intentional. Often, you'll catch scope creep, overly broad permissions, or actions that don't align with the role's purpose.

Second, consider tools that parse IAM policies and flag risky grants. Tools like Prowler, Cloudsploit, or custom scripts can check for wildcard actions, overly permissive conditions, or policies that grant privileges you don't need. Integrate these into your CI pipeline so the PR shows not just the diff, but also a risk assessment.

Third, require sign-off from a security team member or senior engineer for any IAM change. This is one area where a second pair of eyes is worth the friction.

Common mistakes

  • Hardcoding account IDs and ARNs: Use Terraform data sources like data.aws_caller_identity.current.account_id to keep ARNs dynamic across environments.
  • Mixing users and roles: If you find yourself creating IAM users for services, you're doing it wrong. Use roles and assume them.
  • Overly broad conditions: A condition like aws:username matching a pattern can be overly permissive. Be specific about which principals and under what conditions.
  • Forgetting the trust policy: A role is useless without a correct assume role policy. Don't just create the permission policy and forget who can use the role.
  • Policy sprawl: Creating a new inline policy for every small permission change leads to chaos. Consolidate related permissions into a single policy, or use managed policies shared across roles.
  • Not validating policies locally: Run terraform validate and terraform plan before pushing. Don't let bad syntax make it to the PR.

Key takeaways

  • Use aws_iam_policy_document data sources instead of raw JSON for readability, validation, and composability.
  • Prefer roles for services and temporary credentials for humans; minimize long-lived IAM users.
  • Always scope permissions to specific resources by ARN; avoid wildcards unless unavoidable, and document why when you use them.
  • Treat IAM changes as security decisions, not routine code changes; require review and use tooling to flag risky grants in pull requests.
  • Keep policies organized, consolidated, and version-controlled; let your git history and pull requests be the audit trail for who has access to what.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles