CoursesOpenTofuState & plan encryption

State & plan encryption

The headline OpenTofu-only feature.

Advanced14 min · lesson 9 of 12

A state file is a ledger, and a ledger writes down everything. It does not hold a tidy summary of what you built. It holds the exact attributes the provider handed back after each API (application programming interface) call: instance identifiers, subnet ranges, connection strings, and, when a resource has a password, that password. Marking a value sensitive in your configuration hides it from the terminal. The bytes on disk do not change at all.

What Is Actually in the Ledger

Here is a small stack that stands up a database. A random_password resource mints the credential, passes it to the database instance, and puts a copy in AWS Secrets Manager (the Amazon Web Services store for secrets). That generated value has to survive between runs, otherwise every plan would want to replace it, so OpenTofu writes it into state as plain text.

terminal
# jq is a command-line reader for JSON files
ls -l terraform.tfstate
jq -r '.resources[] | "\(.type).\(.name)"' terraform.tfstate
jq -r '.resources[] | select(.type=="random_password")
| .instances[].attributes.result' terraform.tfstate
output
-rw-r--r-- 1 dev dev 18244 Jul 21 09:07 terraform.tfstate
aws_db_instance.main
aws_secretsmanager_secret_version.db
random_password.db
q7BvT2!xNpLd0wZs

Sixteen characters of production database password, sitting in a JSON (JavaScript Object Notation) text file that any process running as your user can read. Move that state to an S3 (Simple Storage Service) bucket and the exposure travels with it. Every principal holding s3:GetObject on that key now holds the password, and the bucket's access log records the theft as a completely ordinary read.

The Saved Plan Carries a Copy of the State

This is the part teams miss. A plan file written with tofu plan -out=tfplan is a zip archive, and two of the entries inside it are full copies of your state: tfstate as of this plan, and tfstate-prev from the run before. So any pipeline that saves a plan for review, hands it to a policy engine, or passes it from a plan job to an apply job is shipping the whole ledger around as a build artifact, usually into a system with much looser access rules than the state bucket ever had.

terminal
tofu plan -out=tfplan >/dev/null
file tfplan
unzip -l tfplan
# Pull the embedded state straight back out of the artifact
unzip -p tfplan tfstate | jq -r '.resources[]
| select(.type=="random_password") | .instances[].attributes.result'
output
tfplan: Zip archive data, at least v2.0 to extract, compression method=deflate
Archive: tfplan
Length Date Time Name
--------- ---------- ----- ----
4821 1980-00-00 00:00 tfplan
18244 1980-00-00 00:00 tfstate
18244 1980-00-00 00:00 tfstate-prev
181 1980-00-00 00:00 tfconfig/modules.json
1367 1980-00-00 00:00 tfconfig/m-/main.tf
1104 1980-00-00 00:00 .terraform.lock.hcl
--------- -------
43961 6 files
q7BvT2!xNpLd0wZs

Why Bucket Encryption Does Not Fix This

Turning on SSE-KMS (server-side encryption, where S3 encrypts each object with a key from AWS KMS, the Key Management Service) feels like the answer. It is closer to paying a storage company to lock the warehouse. The lock is real, and it stops the person who steals a disk or comes in through a hole in the wall. It does nothing about the clerk at the counter, because the clerk's whole job is to hand the box to anybody holding a valid membership card. S3 decrypts the object for you on the way out. If your read is authorized, you get plaintext back, and the key was never standing in your way.

Every state-file incident that actually happens is clerk-side. A bucket policy one wildcard too wide. A read-only auditor role that quietly covered the state prefix. A CI (continuous integration) artifact that outlived the job that produced it. A laptop with a stale terraform.tfstate in a cloned repository. Server-side encryption sees none of that, because in every one of those cases the request was authorized.

The Encryption Block

Client-side encryption locks the documents in a box before you ever hand them to the clerk. The clerk still stores the box and still hands it to anyone with a card, but what they hand over is a locked box. OpenTofu has shipped this natively since version 1.7, HashiCorp's Terraform has no client-side equivalent, and for plenty of teams it is the single most concrete reason to run OpenTofu. The configuration splits into two halves that are easy to keep straight. A key_provider block answers where the key comes from. A method block answers which lock that key operates. Then you point one or more targets (state, plan, remote_state_data_sources) at a method.

encryption.tf
terraform {
encryption {
# 1. Where the key comes from
key_provider "aws_kms" "prod" {
# ARN = Amazon Resource Name, the full identifier of the KMS key
kms_key_id = "arn:aws:kms:eu-west-1:444455556666:key/1a2b3c4d-5e6f-7890-abcd-ef1234567890"
region = "eu-west-1"
key_spec = "AES_256" # ask KMS for a 256-bit data key
}
# 2. Which lock that key operates
method "aes_gcm" "prod" {
keys = key_provider.aws_kms.prod
}
# 3. What gets protected
state {
method = method.aes_gcm.prod
enforced = true
}
plan {
method = method.aes_gcm.prod
enforced = true
}
# 4. Other workspaces' state, read via terraform_remote_state
remote_state_data_sources {
default {
method = method.aes_gcm.prod
}
# Per-source override, if another team uses a different key:
# remote_state_data_source "data.terraform_remote_state.net" {
# method = method.aes_gcm.shared
# }
}
}
}

With the aws_kms key provider your state never travels to Amazon. OpenTofu calls kms:GenerateDataKey, and KMS returns one freshly minted 256-bit key twice over: once in the clear, once sealed inside a blob that only your KMS key can open. OpenTofu encrypts the state locally with the clear copy, drops that copy, and files the sealed copy in the state file's own meta section. The pattern has a name, envelope encryption, and reading the file back means calling kms:Decrypt on the sealed blob to get the working key again. Practical consequence: every role that runs tofu needs both kms:GenerateDataKey and kms:Decrypt. That includes a plan-only runner, because it has to read the existing state and it encrypts the plan it writes.

aes_gcm is AES-GCM (Advanced Encryption Standard in Galois/Counter Mode), which encrypts and authenticates in a single pass. Think of a tamper-evident seal. Flip one byte of the ciphertext in the bucket and decryption fails loudly instead of quietly returning garbage. It needs a key of 16, 24 or 32 bytes, which is what key_spec controls on the KMS side and key_length controls on the other providers. No cloud key service available? pbkdf2 grinds a passphrase down into a key using PBKDF2 (Password-Based Key Derivation Function 2): by default 600,000 rounds of SHA-512 (a standard hash function) with a random 32-byte salt stored next to the ciphertext. The rounds are there to make guessing expensive, and the passphrase has a 16 character minimum. There are also key providers for Google Cloud KMS, Azure Key Vault, OpenBao's transit engine, and an experimental external provider that shells out to a program you write.

Keep the Passphrase Out of the Repository

The encryption block gets resolved unusually early, before the dependency graph exists, because state has to be readable before anything else can happen. It cannot reference a data source, a resource attribute, or a provider-defined function. Variables and locals work only when they resolve that early. A passphrase belongs outside version control anyway, and that is what the TF_ENCRYPTION environment variable is for. It accepts the same HCL (HashiCorp Configuration Language, what your .tf files are written in) body as the block, and OpenTofu merges it over whatever the files say, with the environment winning.

encryption.tf (committed to git)
# No keys. No passphrase. Only the guarantee.
terraform {
encryption {
state { enforced = true }
plan { enforced = true }
}
}
terminal
# STATE_PASSPHRASE comes from the CI secret store, never from the repo.
# Unquoted heredoc marker, so the shell expands the variable. Generate the
# passphrase from a plain alphabet: a backtick in it would run as a command.
export TF_ENCRYPTION=$(cat <<EOF
key_provider "pbkdf2" "ci" {
passphrase = "$STATE_PASSPHRASE"
}
method "aes_gcm" "ci" {
keys = key_provider.pbkdf2.ci
}
state { method = method.aes_gcm.ci }
plan { method = method.aes_gcm.ci }
EOF
)
tofu init -input=false && tofu plan -out=tfplan

enforced = true makes the unencrypted method illegal for that target. It is a door that refuses to close unless it locks. If the environment variable goes missing, if somebody clears it out of the CI settings during a migration, if an engineer runs an apply from a laptop that never had it, there is no silent downgrade to plaintext. The run stops before it writes anything.

terminal
unset TF_ENCRYPTION
tofu plan -out=tfplan
output
│ Error: Unencrypted method is forbidden
│ Unable to use unencrypted method since the enforced flag is set.

Turning It On When You Already Have State

A brand new project needs one apply and nothing else. A stack that already holds plaintext state needs an extra step, because OpenTofu will not guess about this. Point it at a method and it flatly refuses to read the file already sitting there, on the grounds that an unencrypted file could have been tampered with.

terminal
tofu plan
output
│ Error: Failed to load state
│ encountered unencrypted payload without unencrypted method configured

The fix is to say out loud, in configuration, that the existing file is legitimately unencrypted, by wiring an unencrypted method in as a fallback. On read, OpenTofu tries the primary method first and falls back only if that fails. On write, it always uses the primary. So one tofu apply (or anything else that writes state) rewrites the file as ciphertext, after which you delete the fallback and set enforced = true. The two cannot coexist, because enforcement is exactly what makes the unencrypted method illegal.

encryption.tf (temporary, during migration)
terraform {
encryption {
key_provider "aws_kms" "prod" {
kms_key_id = "arn:aws:kms:eu-west-1:444455556666:key/1a2b3c4d-5e6f-7890-abcd-ef1234567890"
region = "eu-west-1"
key_spec = "AES_256"
}
method "aes_gcm" "prod" { keys = key_provider.aws_kms.prod }
method "unencrypted" "migrate" {}
state {
method = method.aes_gcm.prod # always used to WRITE
fallback {
method = method.unencrypted.migrate # only used to READ the old file
}
}
}
}
# tofu apply -> state is rewritten encrypted
# then delete the fallback block and the unencrypted method,
# and set enforced = true

Key rotation pulls the same lever, and you do want it, since there is a limit to how much data one AES-GCM key should ever protect. Make the new method primary, keep the old method as the fallback, run an apply so every file is rewritten under the new key, then remove the fallback. Skip that dance and you get decryption failed for all provided methods, which is the state file politely explaining that the only key which opens it is the one you deleted. Renames spring the same trap. The sealed key is filed under the exact address key_provider.aws_kms.prod, so renaming that block looks identical to a missing key unless you set encrypted_metadata_alias to keep the old name in the file.

Verify It, Do Not Assume It

Read the object back from the backend with tofu out of the loop. That is the only check that proves the bytes at rest are ciphertext, and it takes about ten seconds.

terminal
# Raw object from the backend, no tofu involved ("-" means write to stdout)
aws s3 cp s3://acme-tofu-state/prod/network.tfstate - | jq 'keys'
aws s3 cp s3://acme-tofu-state/prod/network.tfstate - \
| jq '{serial, encryption_version, wrapped_by: (.meta | keys)}'
# The saved plan is an envelope now, not a zip archive
unzip -l tfplan
head -c 72 tfplan; echo
output
[
"encrypted_data",
"encryption_version",
"lineage",
"meta",
"serial"
]
{
"serial": 42,
"encryption_version": "v0",
"wrapped_by": [
"key_provider.aws_kms.prod"
]
}
Archive: tfplan
End-of-central-directory signature not found. Either this file is not
a zipfile, or it constitutes one disk of a multi-part archive. In this
case the central directory and zipfile comment will be found on the
last disk(s) of this archive.
unzip: cannot find zipfile directory in one of tfplan or
tfplan.zip, and cannot find tfplan.ZIP, period.
{"meta":{"key_provider.aws_kms.prod":"eyJjaXBoZXJ0ZXh0X2Jsb2IiOiJBUUlEQU

Three things to notice. encrypted_data is one opaque base64 blob (base64 writes raw bytes as ordinary text characters), so there are no resource names, no attribute keys, nothing to grep for. meta names the key provider address and carries the sealed data key, which is how OpenTofu knows what to ask KMS for on the next read. And serial plus lineage stay readable on purpose, because remote backends compare them to detect a concurrent write. Somebody with bucket read access learns that this workspace is on revision 42, and nothing else.

The Audit Trail You Did Not Have Before

One side effect of the KMS key provider is worth building a detection on. Before encryption, somebody reading network.tfstate out of the bucket generated an S3 data event at best, and nothing at all if data events were never switched on for that bucket (they are off by default and they cost money). With the aws_kms key provider, that same person has to call kms:Decrypt against your key, and KMS records every one of those calls in CloudTrail (the AWS log of API calls) as a management event: on by default, free, with the caller identity attached.

terminal
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt \
--start-time "$(date -u -d '6 hours ago' +%FT%TZ)" \
--query 'Events[].[EventTime,Username]' --output text
output
2026-07-21T09:14:22+00:00 tofu-ci-deploy
2026-07-21T09:14:19+00:00 tofu-ci-deploy
2026-07-21T08:52:03+00:00 tofu-ci-deploy
2026-07-21T08:41:55+00:00 alice.dev

Three of those are the pipeline. One is not. Event history takes only one lookup attribute at a time, so that command pulls every Decrypt in the account and you match the key ARN yourself from the event record. Write the real detection the same afternoon you write the key policy: alarm on any kms:Decrypt against the state key where the caller is not your pipeline role, and scope the key policy tightly enough that only that role and one named break-glass role can use the key at all. The ciphertext is the wall. The alarm is the part that tells you somebody walked up and tried the door.

What state and plan encryption does and does not cover
Ciphertext once you enable it
Remote state object
S3, GCS and HTTP backends store the envelope only
Local state files
terraform.tfstate and terraform.tfstate.backup
Saved plans
tofu plan -out=tfplan, including CI artifacts
terraform_remote_state reads
covered by remote_state_data_sources
Still readable in the file
serial and lineage
passed through so backends can detect conflicts
Object key and size
s3://.../prod/network.tfstate still names the env
Lock records
hold a digest and lock holder, never attributes
Encryption never touches these
tofu state pull
prints fully decrypted JSON to your terminal
tofu show -json tfplan
plaintext plan for policy tools and logs
CI job output
non-sensitive attributes print as normal
.terraform/ and provider calls
credentials and API traffic, handled elsewhere
Client-side encryption protects files at rest. Anything that runs after tofu decrypts is a separate problem you still have to design for.
Lose the key, lose the state
Client-side encryption moves your single point of failure from the bucket to the key. If the KMS key gets scheduled for deletion, if the OpenBao unseal shares are gone, if the passphrase lived only in one person's password manager and that person left, the state file is math rather than data and no support ticket recovers it. Before you turn this on for anything real: keep the key in a managed service with a deletion window and an explicit key policy (not a passphrase pasted into a repository), grant every place that runs tofu access to it (each CI runner, each engineer, your break-glass path), back up the encrypted object and the key policy separately, and rehearse the restore once. Also resist renaming key_provider or method blocks afterwards, since the sealed key is filed under that exact address.
Quick check
01Your pipeline runs tofu plan -out=tfplan, uploads tfplan as a build artifact, then runs tofu show -json tfplan > plan.json and feeds plan.json to a policy engine that prints its input on failure. State and plan encryption are both on with AES-GCM and enforced. Which artifact still exposes the database password?
Incorrect — No. Tofu encrypts state before it ever leaves the process, so the backend only ever receives the envelope.
Incorrect — No. The plan target encrypts saved plans, so the uploaded file is the same JSON envelope as the state object.
Correct — Yes. tofu show -json decrypts on purpose and prints everything, so protection ends the moment tofu hands data to something else.
Incorrect — No. The lock record (in the table the S3 backend uses to stop two runs writing at once) holds a digest, a lock ID and who holds it, never resource attributes.
02You enable client-side state and plan encryption with the aws_kms key provider. Which KMS (Key Management Service) permissions must every role that runs tofu hold — including a plan-only runner?
Incorrect — a plan-only run also encrypts the plan file it writes, so it needs GenerateDataKey too.
Correct — envelope encryption mints a data key with GenerateDataKey on write and reopens the sealed key with Decrypt on read, and a plan run does both.
Incorrect — the state never travels to KMS; OpenTofu encrypts locally with a data key, so it calls GenerateDataKey, not Encrypt.
Incorrect — the aws_kms provider fetches the key from KMS at runtime, so every runner needs KMS access.
03You add an encryption block that points state at aes_gcm with enforced = true to a stack that already has a plaintext terraform.tfstate. The next tofu plan fails with "encountered unencrypted payload without unencrypted method configured." What is the correct fix?
Correct — OpenTofu reads via the fallback but always writes with the primary, so one apply re-encrypts the state, after which you remove the fallback and re-enforce.
Incorrect — that leaves the state unencrypted indefinitely, defeating the reason you turned encryption on.
Incorrect — the fallback mechanism converts plaintext state in place, so a destructive re-import is unnecessary.
Incorrect — -migrate-state moves state between backends; it does not perform the plaintext-to-ciphertext conversion the encryption block handles.

Try this

Run ls -l terraform.tfstate on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: lose the key, lose the state. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related