Open a terraform.tfstate from any project older than a year and you’ll almost certainly find database passwords, private keys, and API tokens sitting in plaintext. It’s always been this way: state is a dump of everything Terraform knows about your infrastructure, including sensitive-marked variable values. OpenTofu — the Terraform fork — got something in 2024 that the original still lacks: client-side encryption of both state and plan. And migrating from Terraform takes an hour, not a sprint.
Table of contents
Open Table of contents
OpenTofu vs Terraform, briefly
OpenTofu was born when HashiCorp switched Terraform’s license from MPL 2.0 to BSL in 2023 — the code stayed readable, but using it in competing products got banned. The Linux Foundation forked the last MPL version, and OpenTofu has since lived as a community-governed project rather than one owned by a single company.
In practice that means three things:
- License. MPL 2.0 — free to use anywhere, including commercial IaC platforms that BSL Terraform forbade.
- Compatibility. Same HCL, same state format, same Terraform Registry for providers and modules. Code written for Terraform runs unmodified in the vast majority of cases.
- Features. The branches diverged: OpenTofu gained provider
for_each, built-in state/plan encryption, and several testing improvements that Terraform lacks and is unlikely to get soon.
Of all the new features, state encryption is the strongest argument for migrating — it closes a gap teams have lived with for years, and it does so without bolting on third-party tooling.
The problem: state is a plaintext secrets dump
Terraform state stores every resource attribute, including ones marked sensitive. The sensitive flag only hides a value in CLI output — in the actual state JSON it sits in plaintext. The same goes for plan files, which teams routinely save as CI artifacts to feed a separate apply job.
Backends — S3, Azure Blob, GCS, Terraform Cloud — usually encrypt data at rest at the storage layer. But that encryption is transparent to anyone with read access to the object: S3 decrypts the content automatically before returning it, as long as the caller has s3:GetObject. So the real security perimeter is the bucket’s IAM policy, not crypto. Leak the bucket credentials, and the whole state leaks in the clear.
Client-side encryption: how OpenTofu does it
Since 1.7, OpenTofu can encrypt state and plan before the data leaves for the backend, and decrypt them after pulling them back locally. In this scheme the backend only ever sees ciphertext — even an attacker with full read access to the S3 bucket gets a meaningless blob without the key.
Configuration lives in an encryption block inside terraform {} and has three parts: a key provider (where the key material comes from), a method (the algorithm — in practice always aes_gcm), and a binding of that method to state/plan:
terraform {
encryption {
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
state {
method = method.aes_gcm.main
}
plan {
method = method.aes_gcm.main
}
}
}
There are several key providers, each covering a different scenario:
pbkdf2— the key is derived from a password/passphrase. Simple to start with, but you still need to put that passphrase somewhere — typically aTF_VAR_...environment variable, never in code.aws_kms/gcp_kms— key material comes from a cloud KMS. Rotation, access auditing, and key-use permissions become the cloud’s job — the right choice for a team.- external providers (
external) — an arbitrary program hands back a key over a simple JSON protocol. This is how you wire in Vault/OpenBao’s Transit engine or any secret store of your own.
The whole file gets encrypted — not individual fields, but the entire state or plan JSON, metadata included. That’s why, once encryption is on, the backend holds an unreadable binary blob rather than a state file with a few fields blacked out.
Backend encryption is not the same thing
The confusion comes up constantly: “we’ve got SSE-KMS on our S3 bucket, isn’t that enough?” No, and the reason is fundamental.
Backend encryption (SSE-S3, SSE-KMS, Azure Storage Service Encryption) protects against disk theft — a stolen physical medium or a storage snapshot pulled outside the API. It protects nothing against a legitimate read request: if the caller holds the IAM right, the backend decrypts and hands back plaintext on its own. That’s encryption of transport and storage, not encryption of the data for the recipient.
OpenTofu’s client-side encryption protects the content itself: state is encrypted before it’s sent and stays ciphertext for anyone without the key — including a bucket admin who lacks rights on the KMS key. The two layers address different threats and work well together: SSE closes off physical access to the disk, client-side encryption closes off excessive IAM grants and logs that state might have leaked into.
Comparison
| Backend encryption (SSE) | OpenTofu client-side encryption | |
|---|---|---|
| What it encrypts | the disk/object in storage | the state/plan JSON itself |
| Who sees plaintext | anyone with an IAM right on the backend | only the holder of the encryption key |
| Transparency to a reader | full (auto-decrypted) | none without the key |
| Configured at | the backend side (S3/Azure/GCS) | the OpenTofu side, encryption block |
| Protects against | disk/snapshot theft | excess IAM rights, logs, prying eyes at the backend |
| Present in Terraform | yes | no |
What you need to encrypt an existing state file
The project already has a state file describing real infrastructure — you need to switch it to encrypted mode without downtime and without risking the data. The mechanism is fallback: OpenTofu tries the primary (encrypting) method, and if reading fails, falls back to a secondary one.
First, add encryption with a fallback for reading the old plaintext state:
terraform {
encryption {
key_provider "aws_kms" "main" {
kms_key_id = "alias/tofu-state"
region = "eu-central-1"
key_spec = "AES_256"
}
method "aes_gcm" "main" {
keys = key_provider.aws_kms.main
}
method "unencrypted" "migrate" {}
state {
method = method.aes_gcm.main
fallback {
method = method.unencrypted.migrate
}
}
}
}
Next, a single tofu apply (or even tofu refresh) does it: OpenTofu reads the current plaintext state through the fallback, and writes back an encrypted version using the primary method. After that, verify on the backend that the file is genuinely binary, then remove the method "unencrypted" "migrate" block along with fallback — there’s no point keeping it, it no longer buys you any backward compatibility.
How to verify secrets are no longer in plaintext
The most reliable check is looking at the object in the backend by hand, rather than trusting the config:
aws s3 cp s3://my-bucket/env/prod/terraform.tfstate - | file -
# expected: data (not ASCII text, not JSON)
aws s3 cp s3://my-bucket/env/prod/terraform.tfstate - | grep -ai "password\|secret"
# expected: nothing — before migration this would have found matches
If file recognizes valid JSON instead of binary data, encryption either didn’t apply, the config wasn’t picked up, or state is still being written by the old terraform binary instead of tofu. Check this before treating the migration as done.
Pitfalls
- Key rotation. Rotating a KMS key or passphrase follows the same pattern as the initial migration: the new method becomes primary, the old one goes into
fallback, wait until state and every teammate’s local copy have been rewritten with the new key, then drop the fallback. Skip this step and you end up with a state file part of the team can’t read. - Local copies and plan artifacts. A
tofu plan -out=tfplansaved as a CI artifact gets encrypted by the same mechanism — only someone with access to the key can decrypt it. Ifapplyruns from that saved plan on a separate CI runner without KMS rights, the pipeline breaks right after encryption goes live, not before. - Team workflows and a lost passphrase. With
pbkdf2, the passphrase is the only way in: lose it without a backup and you’ve lost the state permanently — there’s no “reset password” here. That’s exactly why cloud KMS is almost always the better choice for a team: the cloud manages the key, access is granted and revoked through IAM instead of being shared around in developers’ environment variables. terraformandtofuin the same pipeline. If part of your jobs still call theterraformbinary, it simply won’t understand theencryptionblock and will fail (or, worse, with mismatched versions in play, start handling state in unexpected ways). The migration needs to switch every place the tool runs at once, including developers’ local machines.
Bottom line
State encryption is a rare case in IaC where the entry cost is close to zero and the gap it closes is years old and real: secrets that sat in plaintext at a backend reachable by IAM right, not by actual key ownership. Migrating from Terraform to OpenTofu for this one feature alone is justified on its own — HCL and state-level compatibility make the move a one-day task, not a project. If your state isn’t encrypted client-side anywhere yet, this is the cheapest security win available this week.