TerraformXIX · Security: Credentials, Secrets, and AuditProduction Terraform
Least Privilege for the Terraform Execution Role
What you'll learn
- Bound the Terraform execution role by environment, by resource ARN prefix, and by tag
- Distinguish the role boundary (permissions boundary), the resource boundary (ARN prefix), and the SCP (org-wide ceiling)
- Audit a Terraform role with IAM Access Analyzer and `iam:GenerateServiceLastAccessedDetails`
- Recognise the production signals of an over-privileged Terraform role
- Quantify the cost of over-granting in terms of blast radius and incident recovery time
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A Terraform execution role is the AWS IAM role that Terraform assumes to issue API calls against your infrastructure. The role has a policy. The policy is the authorisation boundary. The credential is OIDC or SSO; the blast radius is the policy.
The production question is not “what permissions does Terraform need?” (it needs whatever the configuration declares). The question is “what is the smallest set of permissions that lets this configuration apply, broken out by environment, by resource ARN, and by tag?” Every action that is not in that set is a permissions gap that turns an incident into a compromise.
This lesson covers the three boundaries (role boundary, resource boundary, SCP), the policy shape that respects them, the audit that proves they are respected, and the cost of over-granting.
The three boundaries
A Terraform execution role is bounded along three orthogonal axes:
SCP (org ceiling)
"what the role cannot do at all"
|
v
Permissions boundary (role ceiling)
"what the role cannot do even if the
inline policy says so"
|
v
Identity-based policy (the role's actual
permissions; must be a subset of both)
|
v
Resource-based policies (per-resource
gates; for example, S3 bucket policy denies
deletion outside the role)
A correct production setup tightens all three. A common mistake tightens one and ignores the other two.
The SCP (organisational ceiling)
Service Control Policies are AWS Organisations-level deny-by-default rules. A production SCP for Terraform roles looks like:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDangerousActionsForTerraformRoles",
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"iam:AttachUserPolicy",
"iam:CreateAccessKey",
"organizations:*",
"account:*"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:PrincipalTag/Role": "terraform"
}
}
}
]
}
The SCP denies the actions even if the role’s identity-based
policy allows them. The aws:PrincipalTag/Role condition
limits the SCP to Terraform roles; it does not affect other
workloads. The SCP is the org-wide safety net.
The permissions boundary (role ceiling)
A permissions boundary is a managed policy attached to the role that defines the maximum permissions the role can ever have. The role’s identity-based policy is intersected with the boundary; only actions allowed by both are effective.
resource "aws_iam_policy" "terraform_boundary" {
name = "terraform-permissions-boundary"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowOnlyTerraformActions"
Effect = "Allow"
Action = [
"ec2:*",
"s3:*",
"iam:*",
"rds:*",
"lambda:*"
]
Resource = "*"
},
{
Sid = "DenyIAMPrivilegeEscalation"
Effect = "Deny"
Action = [
"iam:AttachUserPolicy",
"iam:AttachRolePolicy",
"iam:PutUserPolicy",
"iam:PutRolePolicy",
"iam:CreateAccessKey"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role" "terraform_prod" {
name = "terraform-prod"
permissions_boundary = aws_iam_policy.terraform_boundary.arn
# ...
}
The boundary allows everything Terraform needs; it denies the specific privilege-escalation paths that a misconfigured inline policy could open.
The resource boundary (ARN prefix)
The identity-based policy is the most specific layer. It names the actions and the ARNs. A production policy for an S3-only Terraform workspace looks like:
resource "aws_iam_role_policy" "terraform_prod_s3" {
name = "terraform-prod-s3"
role = aws_iam_role.terraform_prod.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
]
Resource = [
"arn:aws:s3:::prod-tfstate-${var.region}",
"arn:aws:s3:::prod-tfstate-${var.region}/*"
]
},
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
]
Resource = "arn:aws:dynamodb:${var.region}:${var.account}:table/prod-tflock"
}
]
})
}
Note the explicit bucket ARN prefix in Resource. The role
can read and write only the production state bucket. It
cannot list or read any other bucket in the account. The
blast radius is the state bucket, not the account.
Tag-based scoping for multi-environment roles
For multi-environment estates (dev, staging, prod) the production pattern is one role per environment, with tag conditions that scope the actions to the environment’s tag. Which condition key you reach for depends on whether the resource exists yet.
ec2:ResourceTag/<key> matches the tags already on a
resource, so it applies to actions against something that is
already there — terminating, stopping, modifying:
data "aws_iam_policy_document" "terraform_prod_existing" {
statement {
sid = "ActOnProdTaggedInstances"
effect = "Allow"
actions = [
"ec2:TerminateInstances",
"ec2:StopInstances",
"ec2:ModifyInstanceAttribute"
]
resources = ["arn:aws:ec2:eu-west-1:123456789012:instance/*"]
condition {
test = "StringEquals"
variable = "ec2:ResourceTag/Environment"
values = ["prod"]
}
}
}
ec2:RunInstances is different, because it creates the
instance. There is no existing resource to carry a tag, so
ec2:ResourceTag on the instance does nothing useful there.
The tags that matter are the ones in the request, and the
keys for those are the global aws:RequestTag/<key> and
aws:TagKeys:
data "aws_iam_policy_document" "terraform_prod_launch" {
# The request has to reach the AMI, subnet, security group,
# and key pair; those are pre-existing, so no request-tag
# condition applies to them.
statement {
sid = "LaunchUsingProdNetwork"
effect = "Allow"
actions = ["ec2:RunInstances"]
resources = [
"arn:aws:ec2:eu-west-1::image/*",
"arn:aws:ec2:eu-west-1:123456789012:subnet/*",
"arn:aws:ec2:eu-west-1:123456789012:network-interface/*",
"arn:aws:ec2:eu-west-1:123456789012:security-group/*",
"arn:aws:ec2:eu-west-1:123456789012:key-pair/*"
]
}
# The instance and volume are created by the call, so the
# condition is on the tags carried in the request.
statement {
sid = "LaunchOnlyProdTaggedInstances"
effect = "Allow"
actions = ["ec2:RunInstances"]
resources = [
"arn:aws:ec2:eu-west-1:123456789012:instance/*",
"arn:aws:ec2:eu-west-1:123456789012:volume/*"
]
condition {
test = "StringEquals"
variable = "aws:RequestTag/Environment"
values = ["prod"]
}
}
# Tagging at launch is a separate action. ec2:CreateAction
# confines it to the RunInstances call.
statement {
sid = "TagOnLaunchOnly"
effect = "Allow"
actions = ["ec2:CreateTags"]
resources = ["arn:aws:ec2:eu-west-1:123456789012:*/*"]
condition {
test = "StringEquals"
variable = "ec2:CreateAction"
values = ["RunInstances"]
}
}
}
The role can launch an instance only when the request tags it
Environment=prod, and it can act on an existing instance
only when that instance already carries the tag. Both
conditions are enforced by AWS on every API call.
The audit: prove the role is least-privilege
Two AWS tools audit a Terraform role.
IAM Access Analyzer
Access Analyzer reviews the role’s policy and reports
grants that are overly broad. A policy with
Action: "s3:*" and Resource: "*" is flagged as
“inferred access to all S3 resources”. The report is the
proof that the policy is not least-privilege.
# READ-ONLY
aws accessanalyzer list-findings \
--analyzer-arn arn:aws:access-analyzer:eu-west-2:123456789012:analyzer/OrganisationAnalyzer \
--filter '{"resource": {"contains": "terraform-prod"}}'
Service-last-accessed details
iam:GenerateServiceLastAccessedDetails produces a report
of which AWS services the role has actually used in the
trailing 90 days. Services with no activity are candidates
for removal.
# CONFIGURATION — generates a job; READ-ONLY result
aws iam generate-service-last-accessed-details \
--arn arn:aws:iam::123456789012:role/terraform-prod
JOB_ID=$(...)
aws iam get-service-last-accessed-details --job-id "$JOB_ID"
The report returns:
Service: ec2 Last accessed: 2026-08-13T03:14:07Z
Service: s3 Last accessed: 2026-08-13T03:14:09Z
Service: iam Last accessed: 2026-08-12T19:01:22Z
Service: rds Last accessed: 2026-04-02T11:00:00Z # 4 months ago
Service: kms Last accessed: 2026-03-15T09:00:00Z # 5 months ago
rds and kms have not been used in months. They are
candidates for removal. Every action removed is a path closed
in the next incident.
simulate-principal-policy
Simulate the policy against a specific API call to confirm the action is allowed.
# READ-ONLY
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/terraform-prod \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::prod-tfstate-eu-west-2/tfstate/prod/terraform.tfstate
{
"EvaluationResults": [
{
"EvalActionName": "s3:GetObject",
"EvalResourceName": "arn:aws:s3:::prod-tfstate-eu-west-2/tfstate/prod/terraform.tfstate",
"EvalDecision": "allowed"
}
]
}
Run the same simulation with s3:GetObject against
arn:aws:s3:::other-bucket/key:
{
"EvalActionName": "s3:GetObject",
"EvalResourceName": "arn:aws:s3:::other-bucket/key",
"EvalDecision": "implicitDeny"
}
implicitDeny is the correct answer. The role cannot read
the other bucket. The blast radius is bounded.
How to validate the configuration
Run the audit and confirm:
- Access Analyzer reports zero findings for the Terraform roles. A finding means a grant is too broad.
- Service-last-accessed has no service used within 90 days that is still in the policy. Any such service is a removal candidate.
simulate-principal-policyreturnsallowedfor the actions and ARNs the configuration needs, andimplicitDenyfor everything else.
If any check fails, the policy is not yet least-privilege.
Production failure modes
Six failures account for most production incidents:
-
AdministratorAccesson a Terraform role. The role was grantedAdministratorAccessin 2022 to unblock a failing apply. The blast radius is the entire account. The fix is a permissions boundary and an explicit identity-based policy, broken out per environment. -
Wildcard resource in a narrowly-scoped action. The policy has
Action: "ec2:RunInstances"andResource: "*"because the engineer did not know which AMI ARN would be used. The role can launch instances in any environment. The fix is an explicit ARN list for the pre-existing resources the call touches, plus anaws:RequestTagcondition on the instance and volume ARNs the call creates. -
Permissions boundary missing. The inline policy is tight; the boundary is not set. A future engineer widens the inline policy; nothing bounds them. The fix is a permissions boundary attached to the role and a Terraform module pattern that makes the boundary mandatory.
-
No tag condition on cross-environment roles. The same role manages dev and prod. The policy is
Action: "ec2:*". The role can terminate a prod instance from aterraform applyagainst the dev workspace. The fix is one role per environment, with a tag condition. -
Audit runs but no one acts on findings. Access Analyzer reports 14 findings. The team triages 2 and ignores the rest. Six months later, a leaked credential exploits one of the unaddressed findings. The fix is an SLA on finding triage (for example, critical findings within 24 hours) and a quarterly review of open findings.
-
Service-last-accessed never reviewed. The policy has not been audited in two years. It includes services the role stopped using in 2024. The fix is a quarterly review, with removal of unused services from the policy.
Security and performance implications
The performance cost of a tight policy is zero; the AWS control plane evaluates the policy at the same speed regardless of how many statements it contains, up to the documented limits (six policies per role, 6,144 characters per policy).
The security cost of a tight policy is operational: every
new resource type the configuration declares may need a new
action. A policy that does not allow rds:CreateDBInstance
produces a terraform apply that fails with an
AccessDenied. The fix is either to add the action (with
the corresponding ARN scoping) or to redesign the
configuration so the resource is managed by a different
workspace.
The cost of over-granting is incident scope. A leaked
AdministratorAccess key requires an account-wide audit,
a forced rotation of every IAM user, and a credential reset
for every workload that uses IAM. The recovery time is
measured in days. A leaked role with a tight policy
(Action: "s3:*" on the state bucket) requires a state
bucket audit and a state-file rotation. The recovery time is
measured in hours. The difference is the cost of the
original policy decision.
What to do in production
The minimum audit stack for a production Terraform estate:
- Permissions boundary attached to every Terraform role, managed in Terraform, with the boundary mandatory in the module pattern.
- SCP at the org root denying the IAM and account actions that a Terraform role should never perform.
- Tag-based resource scoping for cross-environment roles, with one role per environment.
- Access Analyzer enabled at the org level, with findings reviewed weekly and triaged within an SLA.
- Service-last-accessed audit quarterly, with unused services removed from the policy.
simulate-principal-policyrun as part of the pre-merge CI check on any change to the role module.
Verification
Run Access Analyzer against the Terraform roles and confirm
zero findings. Run simulate-principal-policy against the
actions and ARNs the configuration actually needs and
confirm allowed. Run it against an unrelated ARN and
confirm implicitDeny. Generate the service-last-accessed
report and confirm the policy contains no service unused
for more than 90 days. Confirm the SCP denies
iam:CreateAccessKey and organizations:* even when called
under the Terraform role.
Knowledge check · 7 questions
Q1. What is the role of a permissions boundary on a Terraform IAM role?
Q2. Which of the following is the strongest argument against `Action: "*"` and `Resource: "*"` on a Terraform role?
Q3. A tag condition on a Terraform role (for example `ec2:ResourceTag/Environment = prod`) is sufficient to scope the role to the production environment.
Q4. Which AWS tools audit whether a Terraform role is least-privilege? (Select all that apply.)
Q5. What does `iam:GenerateServiceLastAccessedDetails` tell you about a Terraform role?
Q6. Service Control Policies (SCPs) deny actions at which level?
Q7. A leaked Terraform CI credential is discovered at 03:00. The role is `Action: "*"` and `Resource: "*"`. The team rotates the credential. What remains?
Passing score: 75%. Answers are checked in this browser.