Terraform Security: State File Protection, Provider Pinning, and Plan Review Automation
Problem
Terraform state files contain every secret, IP address, and configuration detail of your infrastructure in plaintext JSON. Anyone with state file access can read database passwords, API keys, and TLS private keys. Provider plugins are downloaded from the internet with minimal integrity verification by default. terraform apply in CI runs with permissions broad enough to create, modify, and destroy any resource.
Threat Model
- Adversary: Attacker who compromises the state file backend (reads all secrets), hijacks a provider download (injects malicious code into the provider binary), or modifies a Terraform module (infrastructure as code injection).
- Blast radius: State file compromise: all secrets for all managed resources. Provider compromise: arbitrary code execution during plan/apply. Module compromise: infrastructure provisioned with attacker-controlled configuration.
Configuration
State File Encryption and Access Control
# backend.tf - S3 backend with encryption and locking
terraform {
backend "s3" {
bucket = "your-org-terraform-state"
key = "production/terraform.tfstate"
region = "eu-west-1"
encrypt = true
kms_key_id = "arn:aws:kms:eu-west-1:123456789012:key/abcd-1234-efgh"
dynamodb_table = "terraform-locks"
# Access control: only the CI role and break-glass admin can read state
# Configured via S3 bucket policy and IAM
}
}
// S3 bucket policy: restrict state file access
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::your-org-terraform-state",
"arn:aws:s3:::your-org-terraform-state/*"
],
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": [
"arn:aws:iam::123456789012:role/terraform-ci",
"arn:aws:iam::123456789012:role/break-glass-admin"
]
}
}
}
]
}
Provider and Module Pinning
# versions.tf - pin providers by version AND hash
terraform {
required_version = ">= 1.8.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "5.46.0" # Exact version, not range
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.30.0"
}
}
}
# Generate .terraform.lock.hcl with platform-specific hashes
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_amd64 \
-platform=darwin_arm64
# The lock file contains SHA-256 hashes for each provider binary.
# Commit this file to Git.
# terraform init verifies hashes - fails if the binary doesn't match.
# Example .terraform.lock.hcl entry:
# provider "registry.terraform.io/hashicorp/aws" {
# version = "5.46.0"
# constraints = "5.46.0"
# hashes = [
# "h1:abc123...",
# "zh:def456...",
# ]
# }
Plan Review in CI
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths: ['terraform/**']
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.8.0"
- name: Configure AWS credentials (OIDC, no static keys)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci-readonly
aws-region: eu-west-1
- name: Terraform Init
run: terraform init -input=false
working-directory: terraform/
- name: Terraform Plan
id: plan
run: terraform plan -input=false -no-color -out=tfplan
working-directory: terraform/
continue-on-error: true
- name: Post plan to PR
uses: actions/github-script@v7
with:
script: |
const output = `#### Terraform Plan
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\`
*Review the plan before approving the apply.*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
- name: Security scan with tfsec
uses: aquasecurity/tfsec-action@v1.0.3
with:
working_directory: terraform/
soft_fail: false # Fail the PR if security issues found
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # Requires manual approval
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Configure AWS credentials (OIDC, write access)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci-apply
aws-region: eu-west-1
- name: Terraform Apply
run: terraform apply -input=false -auto-approve
working-directory: terraform/
Sensitive Output Handling
# Mark outputs as sensitive to prevent them from appearing in plan output
output "database_password" {
value = random_password.db.result
sensitive = true
}
output "api_key" {
value = aws_iam_access_key.deploy.secret
sensitive = true
}
# Even with sensitive=true, the value IS in the state file.
# State file encryption (S3+KMS) protects at rest.
# State file access control protects in transit.
Drift Detection
# Scheduled drift detection
# .github/workflows/drift-detection.yml
name: Terraform Drift Detection
on:
schedule:
- cron: '0 6 * * *' # Daily at 06:00 UTC
jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Terraform Plan (detect-only)
run: |
terraform init -input=false
terraform plan -input=false -detailed-exitcode
# Exit code 2 = changes detected (drift)
working-directory: terraform/
continue-on-error: true
id: drift
- name: Alert on drift
if: steps.drift.outcome == 'failure'
run: |
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d '{"text": "Terraform drift detected in production. Review and reconcile."}'
Expected Behaviour
- State file encrypted at rest with KMS; access restricted to CI role and break-glass admin
- All providers pinned by exact version with
.terraform.lock.hclhash verification terraform planposted to every PR for review;tfsecblocks PRs with security issuesterraform applyrequires manual approval via GitHub environment protection- CI uses OIDC (no static credentials); read-only for plan, write for apply
- Daily drift detection alerts on infrastructure changes outside Terraform
Trade-offs
| Control | Impact | Risk | Mitigation |
|---|---|---|---|
| Exact version pinning | Prevents accidental upgrades | Must manually update versions for security patches | Dependabot/Renovate PRs for Terraform provider updates. |
| State file encryption (KMS) | Secrets encrypted at rest | KMS key management; key rotation | Use AWS-managed KMS key with automatic rotation. |
| Plan-in-PR | Every change is reviewed before apply | Slows deployment for urgent changes | Break-glass: apply directly with audit trail. |
| OIDC for CI credentials | No static credentials | OIDC trust policy must be precise | Restrict by repo, branch, and environment. |
| Drift detection | Catches manual changes | Alert fatigue if infrastructure is frequently modified outside Terraform | Import manual changes into Terraform. Reduce manual access to AWS console. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| State file corrupted | terraform plan shows unexpected destroy/recreate |
Plan shows resources being replaced that shouldn’t be | Restore state from S3 versioning. Never modify state files manually (use terraform state commands). |
| Provider hash mismatch | terraform init fails with hash verification error |
CI fails at init step; lockfile hash doesn’t match downloaded provider | Verify the provider release is legitimate. Re-run terraform providers lock. |
| OIDC trust too broad | Unintended repositories can assume the Terraform role | Security audit reveals overly permissive trust policy | Restrict trust policy to specific repository AND branch AND environment. |
| Drift accumulates | Infrastructure diverges from Terraform state | Drift detection alert fires daily | Import manual changes. Restrict console/API access to prevent out-of-band modifications. |
When to Consider a Managed Alternative
State management, plan review, and drift detection require significant CI/CD infrastructure.
- Terraform Cloud: Managed state, plan review UI, drift detection, RBAC, and policy enforcement (Sentinel).
- Snyk (#48) IaC: Scans Terraform for security misconfigurations in CI. Complements tfsec.
Premium content pack: Security-focused Terraform module collection. modules for VPC, security groups, IAM, and Kubernetes cluster provisioning with hardened defaults, state backend configuration, and CI/CD workflow templates.