TerraformXXIII · Policy as CodeProduction Terraform
Encryption-at-Rest and Encryption-in-Transit Policies
What you'll learn
- Require encryption at rest for state backends and plan artefacts
- Require encryption in transit for plan file transfers and provider API calls
- Define the key management controls: customer-managed keys, rotation, key policy
- Schedule the audit cadence that proves encryption is in effect
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
Two encryptions are mandatory in a production Terraform estate that handles anything beyond a public-facing static site: the encryption of state at rest, and the encryption in transit between Terraform and its backend. Both are policy, not configuration. The configuration declares the cipher; the policy guarantees the cipher is in use.
The threat model
A state file maps every resource in the workspace to its attribute values. For an AWS workspace it contains enough information to read internal IPs, IAM roles, KMS key ARNs, security group IDs, and the contents of sensitive outputs that were not redacted in HCL. A leaked state file is a leaked inventory and a leaked IAM role catalogue. The encryption of state is not optional.
Plan files are similar. They contain the same resource attributes, evaluated to the values the apply will use, plus the dependency graph. The plan is what an attacker needs to confirm a backdoor survives a re-apply. The plan file travels between the engine and the backend; that travel is the second encryption surface.
+-----------------------+
| Provider API (TLS) | Encryption in transit (1)
+----------+------------+
^
+----------+------------+
| Terraform CLI |
+----------+------------+
v
+----------+------------+
| Plan file (JSON) | Encryption at rest (2)
+----------+------------+
v
+----------+------------+
| Backend (S3 + KMS) | Encryption at rest (3)
+-----------------------+
Three encryption surfaces. Each requires its own policy.
Surface 1: encryption at rest for state
For S3-backed state, the bucket is configured for server-side encryption with a customer-managed KMS key (SSE-KMS). The policy asserts this.
The S3 backend block:
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "workspaces/prod/terraform.tfstate"
region = "eu-west-2"
dynamodb_table = "acme-tfstate-lock"
encrypt = true
kms_key_id = "arn:aws:kms:eu-west-2:111122223333:key/abcd-..."
}
}
Three controls in the backend block:
encrypt = true— opt-in to server-side encryption. This flag controls SSE-S3 (the AWS-managed key) by default; SSE-KMS requireskms_key_id.kms_key_id— the customer-managed key. The KMS key is separate from the bucket. The bucket does not own the key.- The S3 bucket-level encryption policy itself — defence in depth at the bucket so that any object landing in the bucket is encrypted even if the backend block is wrong.
The policy:
package terraform.state_encryption
deny[msg] {
rc := input.resource_changes[_]
rc.type == "aws_s3_bucket"
rc.change.after.bucket == "acme-tfstate-prod"
rc.change.after.server_side_encryption_configuration == null
msg := "the state bucket has no SSE configuration; mandate bucket-level SSE"
}
And for the backend block, a static check against the configuration:
package terraform.backend
deny[msg] {
backend := input.terraform.backend
backend.type == "s3"
backend.config.encrypt != true
msg := "S3 backend must have encrypt = true"
}
deny[msg] {
backend := input.terraform.backend
backend.type == "s3"
not backend.config.kms_key_id
msg := "S3 backend must declare kms_key_id (customer-managed key)"
}
Surface 2: OpenTofu state encryption (client-side)
OpenTofu 1.7 introduced client-side state encryption. The state is encrypted before it leaves the operator. The backend sees ciphertext only.
terraform {
encryption {
key_provider "pbkdf2" "mykey" {
passphrase = var.state_passphrase
}
method "aes_gcm" "mykey" {
keys = key_provider.pbkdf2.mykey
}
state {
method = method.aes_gcm.mykey
}
plan {
method = method.aes_gcm.mykey
}
}
}
The plan file and the state file both pass through the encryption. The passphrase lives in the operator environment. The cloud never sees the plaintext state. This is the strongest posture for an estate with strict data-residency requirements.
The policy for OpenTofu encryption is two-fold:
- The
encryptionblock is declared at the root module. - The
passphrasesource is constrained to an environment variable or a secrets manager — never a literal.
package terraform.tfe_encryption
deny[msg] {
enc := input.terraform.encryption
not enc.state
msg := "tofu encryption block must declare state encryption"
}
The customer of OpenTofu encryption is typically a regulated shop. For Terraform Cloud, state is encrypted at rest by the service, with Vault-backed key wrapping as an option.
Surface 3: encryption in transit
Backend traffic must travel over TLS 1.2 or above. The policy
is on the backend block and on the provider’s endpoint
configuration.
package terraform.backend
deny[msg] {
endpoint := input.terraform.backend.config.endpoint
startswith(endpoint, "http://")
msg := sprintf("backend endpoint %q must use https://", [endpoint])
}
Self-hosted backends (Consul, OSS S3-compatible stores) often default to HTTP on the LAN. The encryption-in-transit policy asserts TLS regardless of the apparent safety of the network boundary, on the principle that LAN traffic is also leakage.
For provider API calls, the same principle applies. The
provider’s skip_credentials_validation and
skip_metadata_api_check flags must be false. The AWS
endpoint resolver ignores http:// endpoints in production
configurations; a non-TLS provider is hard to set up by
accident, but the static check still belongs in the policy.
Key management for the KMS key
A customer-managed key requires its own controls:
Control Mechanism
-----------------------------------------------------------------------
Rotation Annual automatic rotation enabled on
the KMS key. Old material is
retained per data-retention policy.
Key policy Operations only by the Terraform
service role and the key
administrator role. No wildcard.
Audit CloudTrail logs all
kms:Encrypt and kms:Decrypt calls
against the key. Forwarded to the
SIEM.
Access review Quarterly. The decrypt grants are
enumerated. Grants to departed
engineers are revoked.
Vault backup If the key material is wrapped in
HSM-backed keys (some compliance
regimes), the wrapped material is
backed up to an offline store.
The key policy for the Terraform service role:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowTerraformEncryptDecrypt",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/terraform-service"
},
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"kms:ViaService": "s3.eu-west-2.amazonaws.com"
}
}
}
]
}
The kms:ViaService condition scopes the key to S3 only. A
compromise of the service role does not yield a general
decrypt capability.
Audit cadence
The audit cadence for the encryption policy:
Frequency Check
-----------------------------------------------------------------------
Per-run Sentinel / OPA plan-time policy runs.
Same as the rest of the policy suite.
Daily CloudTrail log forwarded to SIEM.
Alert on any Decrypt call that is not
from the terraform-service role.
Weekly Spot-check a workspace: ensure state
file is SSE-KMS, key policy has not
drifted, and rotation is enabled.
Quarterly Key access review. Departed engineers
removed from decrypt grants. Old plan
artefacts expired from the bucket.
Annually Key rotation schedule audited. CMK
inventory re-checked. Vendor review
(AWS KMS, GCP KMS, Azure Key Vault)
confirms SLA and feature set.
Validating the policy
# Plan-time
conftest test plan.json --policy policy/encryption.rego
# Static (provider defaults, backend block)
conftest parse --policy policy/encryption_static.rego main.tf
PASS - plan.json - state-encrypted
PASS - plan.json - backend-uses-tls
PASS - plan.json - kms-key-cmk
3 tests, 3 passed
A sentinel-side run on Terraform Cloud:
PASS - encryption-at-rest
PASS - encryption-in-transit
PASS - key-policy
3 policies, 9 tests, 0 failures
Failure modes of an encryption policy
Five failure modes to recognise in production:
- Backend block advertises
encrypt = truebut nokms_key_id. The default key is AES-256 with AWS-managed keys. The encryption is on; the company does not control the key. A security audit flags the gap. Mitigate by makingkms_key_idrequired. - Bucket-level SSE absent. A user lands an object in
the bucket outside of Terraform. The backend encrypts
(because SSE-KMS is the bucket default), but a CI artefact
uploaded by another tool might bypass. Defence: bucket
policy enforces
s3:x-amz-server-side-encryptionon PutObject. - KMS key rotation disabled. Customer-managed key created without rotation. The plan is to rotate after six months; six months drift to a year. Mitigate by enabling automatic rotation at key creation time.
- OpenTofu passphrase committed to the state file. The
literal
passphrase = "..."in the configuration is a hard fail. The policy asserts the source is avariablereference, and a CI lint rejects literals. - Decrypt grants accumulate. Engineers come and go. The
decrypt grant list grows. After three years the
terraform-service role is joined by 12 other roles with
kms:Decrypt. Mitigate with quarterly access review; automate it where possible.
Security and performance
Encryption has a small performance cost. The plan-time impact
is negligible — terraform plan does not encrypt; the
encryption happens at the API call. KMS Encrypt and Decrypt
are billable calls; a 5 000-resource apply is in the low
tens of cents against the KMS pricing model.
The S3 read/write time is similar for SSE-S3 and SSE-KMS. The latency cost of KMS is on the first call (in the order of 10 ms); subsequent calls are sub-millisecond.
The bigger performance concern is state size. Plan-time encryption keeps ciphertext identical to plaintext state size; there is no expansion. The trade-off is the operator’s overhead to manage the passphrase — Vault transit, AWS Secrets Manager, or a sealed local file.
What comes next
Encryption closes the confidentiality story on data. The next lesson is about the network surface — what makes a network policy enforceable as code, and what the policy must codify to be effective.
Verification
conftest test plan.json --policy policy/encryption.rego --output json
{
"passed": 3,
"failed": 0,
"warnings": 0,
"filename": "plan.json"
}
For the bucket itself:
aws s3api get-bucket-encryption --bucket acme-tfstate-prod
{
"ServerSideEncryptionConfiguration": {
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:eu-west-2:111122223333:key/abcd-..."
}
}
]
}
}
For KMS key rotation:
aws kms get-key-rotation-status --key-id arn:aws:kms:eu-west-2:111122223333:key/abcd-...
{
"KeyRotationEnabled": true
}
A failing audit returns KeyRotationEnabled: false. The
remediation is to enable rotation and re-run the policy.
Knowledge check · 7 questions
Q1. What is the purpose of kms_key_id in the S3 backend block?
Q2. OpenTofu state encryption is enforced by what?
Q3. Encryption at rest on the state bucket is sufficient evidence of encryption in effect.
Q4. What is the right cadence for a Decrypt access review against the Terraform KMS key?
Q5. Which surfaces does a production encryption policy cover? (Select all that apply.)
Q6. What kms:ViaService condition in a key policy accomplishes?
Q7. A security audit finds that SSE-S3 (AES-256 with AWS-managed keys) is in use across all state buckets, and the company has no access to the keys. The auditor requests a customer-managed key (CMK). What is the minimum change to remediate?
Passing score: 75%. Answers are checked in this browser.