State & plan encryption
The headline OpenTofu-only feature.
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.
# jq is a command-line reader for JSON filesls -l terraform.tfstatejq -r '.resources[] | "\(.type).\(.name)"' terraform.tfstatejq -r '.resources[] | select(.type=="random_password")| .instances[].attributes.result' terraform.tfstate
-rw-r--r-- 1 dev dev 18244 Jul 21 09:07 terraform.tfstateaws_db_instance.mainaws_secretsmanager_secret_version.dbrandom_password.dbq7BvT2!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.
tofu plan -out=tfplan >/dev/nullfile tfplanunzip -l tfplan# Pull the embedded state straight back out of the artifactunzip -p tfplan tfstate | jq -r '.resources[]| select(.type=="random_password") | .instances[].attributes.result'
tfplan: Zip archive data, at least v2.0 to extract, compression method=deflateArchive: tfplanLength Date Time Name--------- ---------- ----- ----4821 1980-00-00 00:00 tfplan18244 1980-00-00 00:00 tfstate18244 1980-00-00 00:00 tfstate-prev181 1980-00-00 00:00 tfconfig/modules.json1367 1980-00-00 00:00 tfconfig/m-/main.tf1104 1980-00-00 00:00 .terraform.lock.hcl--------- -------43961 6 filesq7BvT2!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.
terraform {encryption {# 1. Where the key comes fromkey_provider "aws_kms" "prod" {# ARN = Amazon Resource Name, the full identifier of the KMS keykms_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 operatesmethod "aes_gcm" "prod" {keys = key_provider.aws_kms.prod}# 3. What gets protectedstate {method = method.aes_gcm.prodenforced = true}plan {method = method.aes_gcm.prodenforced = true}# 4. Other workspaces' state, read via terraform_remote_stateremote_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.
# No keys. No passphrase. Only the guarantee.terraform {encryption {state { enforced = true }plan { enforced = true }}}
# 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 <<EOFkey_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.
unset TF_ENCRYPTIONtofu plan -out=tfplan
╷│ 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.
tofu plan
╷│ 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.
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 WRITEfallback {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.
# 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 archiveunzip -l tfplanhead -c 72 tfplan; echo
["encrypted_data","encryption_version","lineage","meta","serial"]{"serial": 42,"encryption_version": "v0","wrapped_by": ["key_provider.aws_kms.prod"]}Archive: tfplanEnd-of-central-directory signature not found. Either this file is nota zipfile, or it constitutes one disk of a multi-part archive. In thiscase the central directory and zipfile comment will be found on thelast disk(s) of this archive.unzip: cannot find zipfile directory in one of tfplan ortfplan.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.
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
2026-07-21T09:14:22+00:00 tofu-ci-deploy2026-07-21T09:14:19+00:00 tofu-ci-deploy2026-07-21T08:52:03+00:00 tofu-ci-deploy2026-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.
key_provider or method blocks afterwards, since the sealed key is filed under that exact address.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?plan target encrypts saved plans, so the uploaded file is the same JSON envelope as the state object.tofu show -json decrypts on purpose and prints everything, so protection ends the moment tofu hands data to something else.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.