Skip to main content
RunBook Academy

TerraformXXIII · Policy as CodeProduction Terraform

Encryption-at-Rest and Encryption-in-Transit Policies

Intermediate⏱ ~12 minbash

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

Not yet marked complete on this device.

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:

  1. encrypt = true — opt-in to server-side encryption. This flag controls SSE-S3 (the AWS-managed key) by default; SSE-KMS requires kms_key_id.
  2. kms_key_id — the customer-managed key. The KMS key is separate from the bucket. The bucket does not own the key.
  3. 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 encryption block is declared at the root module.
  • The passphrase source 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:

  1. Backend block advertises encrypt = true but no kms_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 making kms_key_id required.
  2. 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-encryption on PutObject.
  3. 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.
  4. OpenTofu passphrase committed to the state file. The literal passphrase = "..." in the configuration is a hard fail. The policy asserts the source is a variable reference, and a CI lint rejects literals.
  5. 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

  1. Q1. What is the purpose of kms_key_id in the S3 backend block?

  2. Q2. OpenTofu state encryption is enforced by what?

  3. Q3. Encryption at rest on the state bucket is sufficient evidence of encryption in effect.

  4. Q4. What is the right cadence for a Decrypt access review against the Terraform KMS key?

  5. Q5. Which surfaces does a production encryption policy cover? (Select all that apply.)

  6. Q6. What kms:ViaService condition in a key policy accomplishes?

  7. 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.