DEV Community

Cover image for State Encryption in OpenTofu: How It Works and How to Roll It Out
James Joyner
James Joyner

Posted on

State Encryption in OpenTofu: How It Works and How to Roll It Out

If you've ever cat-ed a Terraform or OpenTofu state file, you already know the uncomfortable truth: it's a plaintext JSON dump of everything your infrastructure knows, including secrets. Database passwords, generated private keys, API tokens injected through providers — they all land in state, in the clear. OpenTofu is the one place where you can fix this at the source, because native state and plan encryption is a first-class OpenTofu feature that upstream Terraform does not have. Here's how it actually works and how I roll it out on existing projects without breaking them.

Why plaintext state is a real risk

State is not a cache you can regenerate. It's the authoritative map between your HCL and the real resources, and OpenTofu has to store the values of attributes to compute diffs. That includes sensitive ones. Marking an output sensitive = true only hides it from the CLI output — it's still written verbatim to state.

On real infra I've seen state end up in three places it shouldn't: an S3 bucket without SSE and with overly broad read IAM, a CI artifact that got uploaded to a build cache, and a developer laptop with terraform.tfstate committed to a feature branch by accident. Backend encryption (like S3 SSE) helps for one of those. It does nothing for the other two, because the moment state leaves the backend it's plaintext again.

OpenTofu's encryption operates at the data layer, before the bytes ever hit the backend or a local file. The state is encrypted at rest everywhere: in the backend, in local copies, in CI artifacts. That's the property I want.

Anatomy of the encryption block

Encryption lives in a terraform { encryption { ... } } block. It has three moving parts: a key provider (where the encryption key comes from), a method (the actual cipher), and targets (state and/or plan) that bind a method to what you want encrypted.

terraform {
  encryption {
    key_provider "pbkdf2" "passphrase" {
      passphrase = var.tofu_encryption_passphrase
    }

    method "aes_gcm" "default" {
      keys = key_provider.pbkdf2.passphrase
    }

    state {
      method = method.aes_gcm.default
    }

    plan {
      method = method.aes_gcm.default
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole minimal setup. pbkdf2 derives a key from a passphrase, aes_gcm is AES-GCM authenticated encryption, and both the state and plan targets use it. Note the passphrase comes from a variable — never hardcode it.

One caveat worth knowing (as of 2026 — check current docs): you generally can't pull the passphrase from a normal input variable defined elsewhere, because the encryption block is evaluated very early, before most of the graph. In practice I feed it from the environment instead, which I'll cover below. Treat the var. reference above as illustrative and prefer the env-var approach for the passphrase itself.

Key providers: passphrase vs KMS

The pbkdf2 passphrase provider is the easiest to start with and the easiest to get wrong operationally. If you lose the passphrase, the state is gone — there is no recovery. It's great for a solo project or a quick proof of concept, but the passphrase becomes a secret you now have to manage carefully.

For anything shared or production, I use a cloud KMS provider so the key material lives in a managed HSM-backed service and access is controlled by IAM:

terraform {
  encryption {
    key_provider "aws_kms" "main" {
      kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/abcd-1234"
      region     = "us-east-1"
      key_spec   = "AES_256"
    }

    method "aes_gcm" "kms" {
      keys = key_provider.aws_kms.main
    }

    state {
      method   = method.aes_gcm.kms
      enforced = true
    }

    plan {
      method = method.aes_gcm.kms
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

There are equivalent gcp_kms and openbao/Vault-style providers too. The pattern is identical: the key_provider block changes, the method and targets stay the same. With KMS, access control and audit logging come for free — I can see in CloudTrail exactly who decrypted state and when, and I can revoke a role without rotating the underlying data key.

Encrypt the plan file too

People forget plan files. A tofu plan -out=tfplan binary contains the same resource values as state, plus the proposed changes. If your CI pipeline runs plan in one job and apply in another, that plan artifact is passed between jobs — and it's just as sensitive as state. The plan {} target above encrypts it with the same method. Do not skip it.

Rolling it out on an existing project with fallback

The scary part is turning encryption on when you already have unencrypted state. If you just add the encryption block, the next tofu plan will fail trying to decrypt state that was never encrypted. The fallback block is the migration escape hatch: it tells OpenTofu "if you can't decrypt with the primary method, treat the data as unencrypted."

terraform {
  encryption {
    key_provider "aws_kms" "main" {
      kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/abcd-1234"
      region     = "us-east-1"
    }

    method "aes_gcm" "kms" {
      keys = key_provider.aws_kms.main
    }

    state {
      method = method.aes_gcm.kms

      fallback {
        # no method = read plaintext state, write encrypted
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The migration is a one-time apply:

tofu plan -out=tfplan
tofu apply tfplan
# state is now written back ENCRYPTED with the primary method
Enter fullscreen mode Exit fullscreen mode

Because fallback with no method means "read as plaintext," OpenTofu can read your old state, and because the primary method is set, it writes the new state encrypted. Run one apply, confirm the state in the backend is now ciphertext, then remove the fallback block so plaintext state can no longer be silently accepted.

Lock it down with enforced

Once you've migrated, add enforced = true on the target. This is the setting that turns encryption from optional to mandatory:

state {
  method   = method.aes_gcm.kms
  enforced = true
}
Enter fullscreen mode Exit fullscreen mode

With enforced, OpenTofu refuses to read or write unencrypted state at all. No accidental fallback, no misconfiguration silently dropping to plaintext. On a team, this is the line that guarantees nobody's local run produces a plaintext terraform.tfstate.

Key rotation

Rotation doesn't require re-encrypting everything in one shot. A method can hold a list of keys; the first is used to encrypt, and all of them are tried for decryption. To rotate, add a new key provider and put it ahead of the old one in the method's keys:

method "aes_gcm" "kms" {
  keys = [
    key_provider.aws_kms.new,   # new key: used for encryption
    key_provider.aws_kms.old,   # old key: still valid for decryption
  ]
}
Enter fullscreen mode Exit fullscreen mode

The next apply writes state encrypted with the new key while still being able to read anything encrypted with the old one. After you've applied and confirmed all state is on the new key, drop the old provider. Same mechanism works for migrating from passphrase to KMS.

CI and secrets handling

For the passphrase provider, feed the secret through the environment, not a .tfvars file. OpenTofu reads encryption config from env vars prefixed appropriately, and in CI I inject it as a masked secret:

export TF_ENCRYPTION='key_provider "pbkdf2" "passphrase" {
  passphrase = "'"$TOFU_PASSPHRASE"'"
}'
tofu apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

For KMS, there's no passphrase to leak — the CI runner just needs an IAM role that can call kms:Decrypt/kms:GenerateDataKey on that key, which is exactly the least-privilege boundary you want. That's the strongest argument for KMS over passphrase in a pipeline: the secret never exists as a string anywhere.

When I get stuck on the migration edge cases — mixed fallback states, rotation ordering, backend quirks — I keep notes and error write-ups in my OpenTofu troubleshooting guides, because the failure messages during a half-migrated encryption rollout are not always obvious.

Takeaway

Turn on encryption with a fallback block, run one tofu apply to migrate existing state, remove fallback, then set enforced = true and encrypt the plan target too. Use pbkdf2 only for throwaway projects and reach for aws_kms/gcp_kms for anything real, so the key lives in a managed service with IAM and audit logging. Rotate by prepending a new key provider ahead of the old one and applying once. Plaintext state is a solved problem in OpenTofu — you just have to opt in.

Top comments (0)