Skip to main content
INS // Insights

Terraform Plan/Apply Roles With No Access Keys in CI

Updated September 2026 · 4 min read
Terraform Plan/Apply Roles With No Access Keys in CI

Terraform CI/CD pipelines are one of the most under-scrutinized places long-lived AWS credentials hide, precisely because they need broad permissions to function — a Terraform apply role often needs to create IAM roles, modify security groups, and touch nearly every service in an account. That combination of broad permissions and a long-lived static key sitting in a CI/CD secrets store is a serious concentration of risk, and it's exactly the kind of thing that should move to OIDC federation first, not last.

Why Terraform Pipelines Deserve Extra Scrutiny

A compromised Terraform apply credential doesn't just expose data — it can modify infrastructure directly, including creating new privileged IAM roles or backdoor access paths that persist even after the original compromise is discovered and "fixed." The permission scope Terraform needs to do its job is inherently close to admin-level for the resources it manages, which makes it a high-value target if it's sitting as a static key.

Splitting Plan and Apply Into Separate Roles

A common and effective pattern separates the terraform plan step (read-only, safe to run on any pull request including from forks, if scoped correctly) from the terraform apply step (write access, restricted to merges into the main branch with required approval):

# plan-role trust policy: broader trigger conditions, read-only permissions
resource "aws_iam_role" "terraform_plan" {
  name = "terraform-ci-plan"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
        StringLike = { "token.actions.githubusercontent.com:sub" = "repo:my-org/infra:*" }
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "plan_readonly" {
  role       = aws_iam_role.terraform_plan.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

# apply-role trust policy: scoped strictly to the protected environment
resource "aws_iam_role" "terraform_apply" {
  name = "terraform-ci-apply"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          "token.actions.githubusercontent.com:sub" = "repo:my-org/infra:environment:production"
        }
      }
    }]
  })
}

This split means a pull request from any contributor can trigger a plan (useful for reviewing proposed changes) without ever having a path to actually apply anything — the apply role's trust policy only trusts workflow runs tied to the protected production GitHub Environment, which requires merge and approval to reach.

No Access Keys, Anywhere in the Pipeline

With this structure, there's no static AWS access key stored in GitHub Secrets at all — each workflow run authenticates via aws-actions/configure-aws-credentials using the GitHub OIDC token, assumes the appropriate role for that specific step, and the credentials expire automatically at the end of the job. There's nothing long-lived for an attacker to exfiltrate from the CI/CD configuration itself.

Handling Terraform State Access Separately

The S3 backend and DynamoDB lock table Terraform uses for state also need IAM permissions, and those should be scoped tightly to the specific state bucket and lock table — not granted broadly as part of a general "Terraform needs S3 access" policy, since state files often contain sensitive values that shouldn't be readable by roles that don't need them.

Frequently Asked Questions

Does this pattern work for other CI/CD platforms besides GitHub Actions?

Yes — GitLab CI, CircleCI, and other OIDC-capable platforms support the same plan/apply role split conceptually, with platform-specific trust policy syntax for the federated principal and subject claims.

How do we handle Terraform modules that need permissions beyond ReadOnlyAccess for planning?

Some providers require additional read permissions beyond the AWS-managed ReadOnlyAccess policy to plan certain resource types accurately — these can be added incrementally to the plan role as specific gaps are identified, still keeping it read-only overall.

What if we need to apply Terraform changes outside of CI, like from a local machine during an incident?

This should be the exception, not the norm, and ideally still uses temporary federated credentials (via aws sso login or a similar mechanism) rather than a static key, even for break-glass manual applies.

Does splitting plan and apply roles add meaningful complexity to the pipeline?

It's a moderate one-time setup cost, but it's a well-established pattern with strong tooling support, and the ongoing operational complexity is minimal once configured — most of the complexity is in getting the initial trust policy conditions exactly right.

Talk to us about eliminating standing credentials from your infrastructure pipeline → rutagon.com/contact or call 907-841-8407.