Skip to main content
RunBook Academy

TerraformXI · State Security and LifecycleProduction Terraform

Backend Access Control

Intermediate⏱ ~12 minbash

What you'll learn

  • Apply least-privilege IAM to the state backend (S3 + DynamoDB + KMS)
  • Enforce per-environment access boundaries (separate roles, separate backends)
  • Audit who has access to state and what they can do
  • Plan credential rotation and the revocation procedure

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

Not yet marked complete on this device.

The state backend is the trust boundary between declared intent and real infrastructure. Who can read it sees the entire production topology. Who can write it declares what exists. The IAM controls on the backend are the production security controls. A bucket policy that says "Principal": "*" is the production equivalent of leaving the keys in the ignition.

Three IAM layers

For an S3 + DynamoDB backend, three policies are in play:

1. S3 bucket policy. Controls who can call S3 APIs against the bucket. The bucket policy is the outermost layer; an IAM principal needs both bucket policy allow and IAM policy allow.

2. DynamoDB policy. Controls who can read and write the lock table. The lock table is the mutex that prevents concurrent applies; access to it is required for every apply.

3. KMS key policy. Controls who can use the encryption key. Without KMS decrypt access, even a successful S3 GetObject returns an encrypted blob.

Each layer must grant the minimum that allows the apply. A principal that needs to read state for debugging does not need to write state or use the lock table. A principal that needs to apply needs all three.

The S3 bucket policy

A production S3 bucket policy for the state bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnforceTLS",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::tfstate-production",
        "arn:aws:s3:::tfstate-production/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    },
    {
      "Sid": "EnforceSSEKMS",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::tfstate-production/*",
      "Condition": {
        "StringNotEquals": {
          "s3:x-amz-server-side-encryption": "aws:kms"
        }
      }
    },
    {
      "Sid": "AllowApplyRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/terraform-apply-production"
      },
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:GetObjectVersion"
      ],
      "Resource": "arn:aws:s3:::tfstate-production/*"
    },
    {
      "Sid": "AllowReadAuditor",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/terraform-auditor"
      },
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::tfstate-production",
        "arn:aws:s3:::tfstate-production/*"
      ]
    },
    {
      "Sid": "DenyEveryoneElse",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::tfstate-production",
        "arn:aws:s3:::tfstate-production/*"
      ],
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalArn": [
            "arn:aws:iam::123456789012:role/terraform-apply-production",
            "arn:aws:iam::123456789012:role/terraform-auditor"
          ]
        }
      }
    }
  ]
}

Five statements:

  1. Deny non-TLS — every request must use HTTPS.
  2. Deny unencrypted uploads — every upload must use SSE-KMS.
  3. Allow apply role — full read/write on objects.
  4. Allow auditor — read-only access; no write.
  5. Deny everyone else — the explicit deny. The bucket is closed by default; only the listed principals are allowed.

The explicit deny at the bottom is the production pattern. Without it, an IAM principal with s3:* in the account could still read the bucket.

The DynamoDB policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:DeleteItem",
        "dynamodb:UpdateItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/tfstate-locks-production"
    }
  ]
}

The lock table requires only the four actions an apply needs. Scan and Query are not granted; the apply does not enumerate locks.

The KMS key policy

{
  "Version": "2012-10-17",
  "Id": "tfstate-production-key-policy",
  "Statement": [
    {
      "Sid": "AllowRoot",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::123456789012:root" },
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "AllowApplyRoleUse",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/terraform-apply-production"
      },
      "Action": [
        "kms:Decrypt",
        "kms:GenerateDataKey",
        "kms:DescribeKey"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AllowAuditorRead",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/terraform-auditor"
      },
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey"
      ],
      "Resource": "*"
    }
  ]
}

The auditor needs decrypt (to read state) but not GenerateDataKey (to write state). The apply role has both. The root principal has admin access for key management, held by a separate security team role.

Per-environment boundaries

The pattern repeated for staging and development:

production:
   S3 bucket:    tfstate-production
   DynamoDB:     tfstate-locks-production
   KMS key:      kms-prod-<id>
   Apply role:   terraform-apply-production
   Auditor role: terraform-auditor (cross-environment read)

staging:
   S3 bucket:    tfstate-staging
   DynamoDB:     tfstate-locks-staging
   KMS key:      kms-staging-<id>
   Apply role:   terraform-apply-staging

development:
   S3 bucket:    tfstate-dev
   DynamoDB:     tfstate-locks-dev
   KMS key:      kms-dev-<id>
   Apply role:   terraform-apply-dev

Three independent backends. The blast radius of a compromised development credential is limited to development. A compromised staging credential cannot read or write production state.

The auditor role can read all three. The cross-environment read is by design — the auditor is authorised for all environments — but no apply role crosses environments.

Audit trail

Every state access should be auditable:

# CloudTrail: every S3 GetObject and PutObject on the state bucket
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceName,AttributeValue=tfstate-production \
  --max-items 50 \
  | jq '.Events[] | {Time, User: .Username, Event: .EventName, Resource: .Resources[].ResourceName}'

# S3 access log: every request to the state bucket
aws s3api get-bucket-logging --bucket tfstate-production

CloudTrail is the primary audit. Enable it on every account that holds state; configure a separate CloudTrail bucket for the logs so the audit trail is not co-located with the data.

For Terraform Cloud, the audit trail is the run history:

tfc organization audit -json | jq '.workspaces[] | .name + ": " + .latest_run.id'

Each run records the operator, the diff, the timestamp, and the outcome.

Credential rotation

The apply IAM role uses an assume-role pattern from CI:

# In CI:
aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/terraform-apply-production \
  --role-session-name "apply-$(date +%s)" \
  --duration-seconds 3600 \
  > /tmp/credentials.json

export AWS_ACCESS_KEY_ID=$(jq -r .Credentials.AccessKeyId /tmp/credentials.json)
export AWS_SECRET_ACCESS_KEY=$(jq -r .Credentials.SecretAccessKey /tmp/credentials.json)
export AWS_SESSION_TOKEN=$(jq -r .Credentials.SessionToken /tmp/credentials.json)

terraform apply

No long-lived IAM access keys for state access. The CI assumes the role per run; the credentials expire in an hour. A leaked CI session token has a one-hour blast radius.

For Terraform Cloud, the credential rotation is handled by the platform; the team does not manage IAM access keys.

Validation

READ-ONLY

# Confirm the bucket policy is what you expect
aws s3api get-bucket-policy --bucket tfstate-production \
  | jq '.Policy | fromjson | .Statement[] | {Sid, Effect, Action}'

# Confirm the apply role has the right access
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/terraform-apply-production \
  --action-names s3:GetObject s3:PutObject dynamodb:PutItem kms:Decrypt \
  --resource-arns arn:aws:s3:::tfstate-production/*

# Confirm the audit trail is enabled
aws cloudtrail describe-trails | jq '.trailList[] | {Name, S3BucketName}'

Production failure modes

Symptom: state operations fail with “AccessDenied” on S3 or DynamoDB. Cause: the apply IAM role lost access. Recovery: re-grant; verify with aws sts get-caller-identity and a dry-run.

Symptom: state operations succeed but the audit log is empty. Cause: CloudTrail is not configured to log data events on the state bucket. Recovery: enable S3 data event logging for the bucket.

Symptom: a developer holds a long-lived IAM access key for production state. Cause: the developer was granted a key during incident response and the key was not removed. Recovery: delete the access key; rotate any state that was exposed; switch to assume-role from CI.

Symptom: the auditor role has write access. Cause: the role was over-permissioned. Recovery: tighten the policy; the auditor needs read only.

Recovery

If the apply IAM role credentials are compromised:

  1. Revoke the credentials (delete the access key or disable the role session).
  2. Audit CloudTrail for the exposure window: what was accessed, what was written.
  3. If state was modified, restore from the versioned backup.
  4. If state was read, rotate any secrets that were in state during the window.
  5. Issue new credentials via a fresh role session.

What comes next

The next lesson covers state security in the CI/CD pipeline: how plan output, debug logs, and CI secrets interact with the state backend.

Verification

  • You can write a least-privilege S3 bucket policy for a state bucket (TLS-only, SSE-KMS-only, explicit apply and auditor roles, explicit deny).
  • You can name the three IAM layers (bucket, DynamoDB, KMS) and explain why each is needed.
  • You can describe the per-environment boundary (separate buckets, separate roles, separate KMS keys).
  • You can audit state access via CloudTrail and the S3 access log.

Knowledge check · 7 questions

  1. Q1. What is the right pattern for a production state S3 bucket policy?

  2. Q2. Who should hold long-lived IAM access keys for the production state bucket?

  3. Q3. An IAM policy that grants s3:GetObject on arn:aws:s3:::tfstate-*/* is safe because the wildcard is on the bucket name, not the action.

  4. Q4. How should production and staging state backends be organised?

  5. Q5. Which IAM layers control state access in an S3 + DynamoDB backend? (Select all that apply.)

  6. Q6. An auditor role needs to inspect production state. Which combination of permissions is correct?

  7. Q7. An ex-developer retained an active IAM access key with s3:PutObject on the production state bucket. The access was not revoked on departure. What is the right immediate action?

Passing score: 75%. Answers are checked in this browser.