TerraformXI · State Security and LifecycleProduction Terraform
State Security Threats
What you'll learn
- Identify the sensitive data that ends up in state (resource IDs, ARNs, secrets passed through)
- Define the threat model: who can read state, who can write state, what they can do
- Recognise the per-environment boundary and why shared state is a high-risk pattern
- Apply the principle of least privilege to state backend access
Prerequisites
None — start here.
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 state file for a moderately sized production estate is a 100–500 KB JSON document. It contains every resource ID, every ARN, every IP address, every output value, every tag. If a database password was ever passed through Terraform — even as a variable — it is in there, in plain text. Anyone who can read the state file can see the entire topology of the production infrastructure. Anyone who can write the state file can declare that anything exists. The threat model for state is the threat model for the entire estate.
What is in state that an attacker wants
Three categories of data, in increasing severity:
1. Resource IDs and ARNs. Every resource has an id attribute
that holds the cloud identifier. EC2 instance IDs, RDS ARNs, S3
bucket names, IAM role ARNs. An attacker who reads state can map
the entire production estate without ever calling the cloud API.
2. Network and configuration metadata. CIDR blocks, subnet IDs, security group rules, IAM policy documents, KMS key ARNs. The full network topology is in state. An attacker can plan lateral movement without any recon.
3. Secrets that flowed through Terraform. This is the bad one.
A random_password resource, a database password passed through
a data source, a TLS private key, an API token. The secret is
in state as a plain-text string. A read of the state file is a
read of the secret.
Threat model for the state file:
Read access → full topology, every ID, every secret in plain text
Write access → declare any infrastructure; lock out the team
Delete access → unmanaged infrastructure, recovery from backup
Who can read and write state in production
In a typical AWS setup with S3 + DynamoDB:
| Role | S3 read | S3 write | DynamoDB access |
|---|---|---|---|
| Apply IAM role | Yes | Yes | Yes |
| Plan IAM role | Yes | No | Yes |
| Read-only auditor | Yes | No | No |
| Developer (read) | No | No | No |
| Developer (apply) | Yes (via CI) | Yes (via CI) | Yes (via CI) |
The principle: the smallest number of IAM principals have write access. Read access is broader but still controlled. No developer has direct state access; every apply runs through a CI pipeline that holds the IAM role.
For Terraform Cloud, the equivalent:
| Role | Workspace read | Workspace write | Variable access |
|---|---|---|---|
| Workspace admin | Yes | Yes | Yes |
| Developer (apply) | Yes (via run) | No (Terraform runs) | No |
| Read-only auditor | Yes | No | No |
| Variable viewer | No | No | Yes |
Terraform Cloud adds a layer: developers do not have IAM to the backend; they trigger runs through the API. The audit log is the Terraform Cloud run history.
The per-environment boundary
Production, staging, and development should have separate state backends, not separate prefixes in a shared bucket.
production:
S3 bucket: tfstate-production
DynamoDB: tfstate-locks-production
KMS key: kms-prod
IAM role: terraform-apply-production
staging:
S3 bucket: tfstate-staging
DynamoDB: tfstate-locks-staging
KMS key: kms-staging
IAM role: terraform-apply-staging
development:
S3 bucket: tfstate-dev
DynamoDB: tfstate-locks-dev
IAM key: kms-dev
IAM role: terraform-apply-dev
Three independent backends. The blast radius of a compromise in development is limited to development. A compromised staging credential cannot read or write production state. The separation is enforced by AWS account boundaries, IAM policies, and KMS key policies.
The threat categories
Four threats to model, in increasing severity:
1. Read by an authorised insider. A developer with read access to production state for debugging purposes sees the entire production topology. This is acceptable if the developer is authorised and the access is logged. The risk is the developer who is authorised but not careful: printing state to a log, copying it to a laptop, sharing it in a Slack DM.
2. Read by an unauthorised party. A misconfigured S3 bucket policy, a leaked IAM credential, a stolen developer laptop. This is a security incident. The response is in the incident lesson.
3. Write by an unauthorised party. A misconfigured IAM role
that allows s3:PutObject to the state bucket. An attacker can
replace the state with one that declares resources they control.
This is a full takeover of the production topology from
Terraform’s perspective.
4. State corruption or deletion. An attacker (or an operator under pressure) deletes the state. Recovery is from the versioned backup, but the recovery window depends on the backup cadence and the RPO.
The principle of least privilege, applied to state
For each IAM principal that touches state, the policy should be the minimum that allows the work:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::tfstate-production/*"
},
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/tfstate-locks-production"
},
{
"Effect": "Allow",
"Action": [
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "arn:aws:kms:us-east-1:123456789012:key/<key-id>"
}
]
}
Three grants: S3 object access to the state bucket only,
DynamoDB access to the lock table only, KMS access to the state
encryption key only. No wildcard resources. No s3:ListBucket
(unless the team genuinely needs to enumerate objects).
The principle extends to the human side:
- Developers do not hold the apply IAM role. CI holds it.
- Developers trigger runs through CI, which assumes the role.
- The CI logs the run, the operator, and the diff.
- The state IAM role is rotated on the same cadence as any other privileged credential.
Validation
READ-ONLY
# Confirm the backend bucket is what you expect
terraform state pull > /tmp/state.json
echo "Bucket: $(grep -oE 'bucket = "[^"]+"' /tmp/state.json || echo 'no bucket field')"
# Confirm encryption-at-rest is on
aws s3api get-bucket-encryption --bucket tfstate-production
# Confirm versioning is on
aws s3api get-bucket-versioning --bucket tfstate-production
# Confirm the IAM role has the minimum required permissions
aws iam get-role-policy --role-name terraform-apply-production --policy-name state-access
For a Terraform Cloud setup:
# Audit workspace access
tfc organization audit -json | jq '.workspaces[] | {name, access: .access}'
Production failure modes
Symptom: state bucket has public read access. Cause: S3 bucket policy misconfiguration; ACL; bucket-level Block Public Access disabled. Recovery: enable Block Public Access at the account level; remove public policies; rotate any IAM credentials that may have been exposed; review access logs for the exposure window.
Symptom: a developer laptop has the production state in a local
file. Cause: an operator ran terraform init against production
without the remote backend; the state file landed locally.
Recovery: configure the remote backend; run a refresh-only plan
to push state; delete the local file; rotate any credentials that
were on the laptop.
Symptom: KMS key policy allows access from a wrong account. Cause: the key policy was written too broadly during setup. Recovery: tighten the key policy; rotate the key; re-encrypt the state with the new key.
Symptom: the apply IAM role has been over-permissioned with
* resources. Cause: the policy was written for convenience
during initial setup. Recovery: rewrite the policy with the
minimum resources; re-test applies.
Recovery
A state security incident is covered in its own lesson. The short version:
- Stop all applies. The state is compromised.
- Identify the exposure: who had access, what they could read, what they could write.
- Rotate any credentials that touched the exposed state.
- Restore state from a clean backup if integrity is in doubt.
- Audit the access logs for the exposure window.
- Post-incident review: how did the exposure happen, what control failed, what changes prevent recurrence.
What comes next
The next lesson covers sensitive = true — the affordance for
marking values that should be suppressed in CLI output, and its
limitations.
Verification
- You can list the categories of sensitive data in state (IDs, ARNs, network metadata, secrets).
- You can name the three roles (read, write, delete) and the blast radius of each.
- You can explain why production and non-production state must live in separate backends.
- You can write an IAM policy that grants the minimum state access for an apply role.
Knowledge check · 7 questions
Q1. Which of these is the most severe consequence of state read access by an unauthorised party?
Q2. Production and non-production state belong in separate buckets, because a shared bucket is a shared blast radius even when the prefixes differ.
Q3. Who should hold the apply IAM role for production Terraform?
Q4. What is the right way to handle a secret that Terraform needs at apply time (for example, a database password)?
Q5. Which of the following are in state and should be considered sensitive? (Select all that apply.)
Q6. An S3 bucket that holds state files has a policy that grants `s3:GetObject` to `arn:aws:s3:::tfstate-*/*`. What is the production concern?
Q7. An ex-engineer retained an active IAM access key with s3:PutObject on the production state bucket. The access was not revoked on their departure. What is the immediate risk?
Passing score: 75%. Answers are checked in this browser.