Least-Privilege Credentials
Terraform is one of the most powerful tools in your stack: it can create, mutate, and destroy your entire infrastructure in a single apply. That power makes the credentials it runs with a prime target and a serious blast-radius risk. Least-privilege means Terraform gets exactly the permissions it needs to manage the resources it owns — no more — and those permissions are scoped per environment and, ideally, short-lived. This page shows how to design scoped IAM roles, separate credentials between dev/staging/prod, and adopt OIDC-based short-lived credentials in CI so there are no long-lived secrets to leak.
Why admin-everywhere is a liability
It is tempting to hand Terraform AdministratorAccess and move on. Doing so means any compromised pipeline, leaked state file, or malicious module can do anything to any account. A misconfigured for_each or an accidental terraform destroy against the wrong workspace becomes a full-account incident instead of a contained one. Scoped credentials turn those mistakes into a clean AccessDenied error.
Warning: A leaked admin key is an account takeover. A leaked key scoped to one S3 bucket and one DynamoDB table is a contained, recoverable event. Always assume credentials will eventually leak and design the blast radius accordingly.
Scope IAM roles to the resources Terraform owns
Start by listing the AWS services your configuration actually touches, then write a policy that grants only those actions. Terraform itself needs read access to describe existing resources during plan, plus the create/update/delete actions for the resources it manages.
A role for a configuration that manages a VPC and an EC2 instance might look like this — note that the policy is itself defined in Terraform via aws_iam_policy_document:
data "aws_iam_policy_document" "terraform_network" {
statement {
sid = "ManageVPC"
effect = "Allow"
actions = [
"ec2:CreateVpc",
"ec2:DeleteVpc",
"ec2:DescribeVpcs",
"ec2:CreateSubnet",
"ec2:DeleteSubnet",
"ec2:DescribeSubnets",
"ec2:CreateTags",
"ec2:RunInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
]
resources = ["*"]
}
}
resource "aws_iam_policy" "terraform_network" {
name = "terraform-network-dev"
policy = data.aws_iam_policy_document.terraform_network.json
}
Where the provider supports it, constrain resources and add condition blocks (for example, requiring a specific tag or region) instead of using "*". Many EC2 actions only support *, so pair those with conditions like aws:RequestedRegion to keep the scope tight:
data "aws_iam_policy_document" "scoped_region" {
statement {
effect = "Allow"
actions = ["ec2:RunInstances"]
resources = ["*"]
condition {
test = "StringEquals"
variable = "aws:RequestedRegion"
values = ["eu-west-1"]
}
}
}
One credential per environment
Never share a single role across dev, staging, and prod. Each environment should map to a distinct IAM role in a distinct account (or at minimum a distinct role with non-overlapping resource scopes). This prevents a dev pipeline from ever mutating prod, and lets you grant developers self-service access to dev while locking prod behind CI only.
| Environment | Account | Role | Who can assume it |
|---|---|---|---|
| dev | 111111111111 | terraform-dev | developers + CI |
| staging | 222222222222 | terraform-staging | CI only |
| prod | 333333333333 | terraform-prod | CI only, with approval gate |
Wire the environment into the provider with assume_role, selecting the account and role from a variable so the same code targets different environments:
provider "aws" {
region = "eu-west-1"
assume_role {
role_arn = "arn:aws:iam::${var.account_id}:role/terraform-${var.environment}"
session_name = "terraform-${var.environment}"
}
}
The base credential that assumes these roles needs only sts:AssumeRole on the specific role ARNs — it has no direct infrastructure permissions of its own.
Short-lived OIDC credentials in CI
The strongest pattern eliminates long-lived secrets entirely. GitHub Actions, GitLab CI, and other providers can mint short-lived OIDC tokens that AWS trades for temporary credentials via an IAM role. There is no access key to store, rotate, or leak.
First, register the GitHub OIDC provider and a role whose trust policy restricts which repository and branch may assume it:
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
data "aws_iam_policy_document" "github_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:my-org/infra:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "github_terraform" {
name = "terraform-prod"
assume_role_policy = data.aws_iam_policy_document.github_trust.json
}
Then the workflow assumes the role with no stored secrets:
# GitHub Actions step (uses the official AWS credentials action)
aws-actions/configure-aws-credentials@v4 \
--role-to-assume arn:aws:iam::333333333333:role/terraform-prod \
--aws-region eu-west-1
terraform init
terraform plan -out tfplan
Output:
Assuming role arn:aws:iam::333333333333:role/terraform-prod
Configured AWS credentials (expires in 1h)
Terraform will perform the following actions:
# aws_instance.app will be created
+ resource "aws_instance" "app" { ... }
Plan: 1 to add, 0 to change, 0 to destroy.
If the scope is too narrow, you get a clear, contained failure rather than silent over-reach:
Output:
Error: creating IAM Role: AccessDenied: User is not authorized to
perform: iam:CreateRole on resource: terraform-prod-app because no
identity-based policy allows the iam:CreateRole action
That error is a feature — it tells you exactly which action to add, one statement at a time.
Tip: Everything here works identically with OpenTofu. Swap the
terraformbinary fortofu; the HCL, providers, and IAM resources are unchanged.
Best Practices
- Grant only the actions your configuration actually uses; start narrow and widen based on real
AccessDeniederrors rather than guessing broad. - Use a separate IAM role per environment, isolated by account where possible, so dev pipelines can never touch prod.
- Prefer OIDC short-lived credentials in CI over long-lived access keys — there is nothing to rotate or leak.
- Restrict OIDC trust policies by repository and branch (the
subclaim) so only the intended pipeline can assume the role. - Add
conditionblocks (region, tags, MFA) to tighten actions that only acceptresources = ["*"]. - Give humans read-only or dev-only access; route all production changes through CI with an approval gate.
- Audit role usage regularly with CloudTrail and prune actions that never appear in real applies.