Sensitive Data in State
Every secret Terraform touches lands in state in plaintext. The enrichment-api-key secret's payload, a generated database password, a service-account key, a TLS private key — state records the attributes Terraform read, and it does not encrypt the values inside the JSON. There is no Terraform setting that strips them out, because storing what it read is how state works.
That makes the hatch-tfstate bucket a high-value target. Whoever can read it can read every secret the pipeline ever provisioned, across every environment whose state lives there. The protection for those secrets is not a flag in your config — it is the access control, encryption, and versioning on the bucket itself, treated as the most sensitive asset in the org.
Why Secrets Are in State at All
Terraform stores what it read, and a secret is just another attribute. A google_secret_manager_secret_version payload, a random_password Terraform generated, a key it created — each is a value the provider returned, so each goes into state verbatim. There is no toggle that keeps the attribute out; if Terraform manages the resource, the resource's attributes are in state, secrets included.
This is not a bug to be fixed but a property to be managed. The right response is not to wish the secret out of state, which is impossible, but to protect the place state lives and to keep secrets out of Terraform's hands where you can. Both halves matter, and the rest of this page is each in turn.
The sensitive Flag Only Redacts Output
Marking a variable or output sensitive hides its value from plan and apply console output and from logs. That is genuinely useful — it keeps a database password out of a CI log that a wide audience can read — but it is the entire extent of what the flag does. The value is still plaintext in the state file. sensitive is about display, never about storage.
output "db_password" { value = google_sql_user.app.password sensitive = true # redacted from CLI/logs — still plaintext in state }
Reading that, it is tempting to think the password is now secured. It is not. The console no longer prints it, and the state JSON still contains it in full. Confusing the redaction for protection is the central mistake of this topic, and it is why an over-permissive state bucket leaks everything regardless of how many sensitive flags the config carries.
Protecting the State Bucket
The real control is the bucket. Lock its IAM so only the Terraform service account and a small set of break-glass admins hold roles/storage.objectAdmin — nobody else reads or writes state. Turn on uniform bucket-level access so there are no per-object ACLs to leak. Set CMEK so reading the bytes also requires access to a key whose every use is logged.
These three together are what keep the plaintext unreadable: tight IAM decides who, uniform access removes the ACL escape hatch, and CMEK makes even the bytes key-gated and auditable. None of them is optional on a bucket that holds the secrets of every environment.
Minimizing Secrets in State
The smaller the set of secrets in state, the smaller the blast radius if it leaks. So do not have Terraform generate or read secrets it does not need to. Where a workload needs a secret at runtime, reference the existing Secret Manager secret by id and let the workload read it itself — rather than pulling the payload into Terraform, where it lands in state forever.
This is a design choice on every resource that touches a secret. A random_password Terraform generates and a secret payload it reads are both one more plaintext value in the bucket; a secret the workload fetches at runtime is none. Fewer secrets in state is fewer secrets to lose, and the cleanest configs keep payloads out of Terraform entirely.
Why the State Bucket Is the Crown Jewels
A single bucket holding plaintext secrets for every environment is a higher-value target than any one application database. Compromise an app database and you get one app's data; compromise the state bucket and you get the credentials for everything Terraform ever provisioned, dev through prod. It deserves the strictest IAM, CMEK, versioning, and audit logging in the organization.
Treat it as a tier-0 asset. Audit who can read it as carefully as you would audit access to your most sensitive customer data, because in effect it is a key to all of it. The hatch-tfstate bucket is not infrastructure plumbing to set up once and forget — it is the one bucket whose access list you review on a schedule.
sensitive — stops a value from printing in plan and apply output and in CI logs, and that is all it does. The byte is still plaintext in state. Use it to keep secrets out of logs, never as the thing that secures them.
Bucket IAM + CMEK + uniform access — what actually keeps the secret unreadable, because it controls who and what can open the state object. This is the real protection; never confuse the redaction for it.
- Believing
sensitive = truesecures the secret — it only hides it from console output while the plaintext sits in the state bucket, so an over-permissive bucket leaks everything regardless. - Granting broad read on the state bucket (
roles/storage.objectViewerto a wide group, or project-level Viewer) — anyone in that group can pull every plaintext secret across every environment. - Leaving per-object ACLs on by not enabling uniform bucket-level access — a single mis-set ACL exposes a state object that bucket IAM looked locked down.
- Having Terraform generate and store secrets it never needed to manage — every
random_passwordor read secret payload is one more plaintext value in the bucket and a larger blast radius if it leaks. - Committing a local state file to git or sharing it over chat to "debug" something — that copies every plaintext secret out of the protected bucket and onto machines and logs with no access control at all.
- Lock the state bucket's IAM to only the Terraform service account and break-glass admins, and audit who has read access as if it were the most sensitive bucket in the org — because it is.
- Enable CMEK and uniform bucket-level access on the state bucket so encryption is key-gated and there are no per-object ACLs to misconfigure.
- Mark sensitive variables and outputs
sensitiveto keep them out of CI logs, while remembering that this protects the logs, not the state. - Keep secrets out of state where possible — reference existing Secret Manager secrets by id and let workloads read them at runtime instead of pulling payloads into state.
- Review the state bucket's access list on a schedule, treating it as a tier-0 asset rather than infrastructure plumbing you configure once and forget.
Knowledge Check
Why do secrets end up in Terraform state in plaintext?
- State records the attributes Terraform read, and a secret is just an attribute, so it is stored verbatim with no setting to strip it
- Terraform deliberately stores secrets in plaintext purely to speed up plans, and you can switch the behavior off with a top-level setting in the backend block whenever you choose
- Only secrets read directly from Secret Manager land in state; locally generated passwords never do
- It is a known bug that was quietly fixed in recent Terraform 1.x releases
What does marking an output sensitive actually protect?
- It keeps the value out of plan/apply output and CI logs; it does nothing to the plaintext sitting in state
- It encrypts that single value inside the state file using the backend's CMEK key at rest, leaving every other attribute stored as readable plaintext
- It moves the value out of state entirely and stores it in Secret Manager instead
- It scrubs the value from state automatically once the apply has finished running
What actually keeps the secrets in state unreadable?
- Tight bucket IAM, CMEK, and uniform bucket-level access on the state bucket
- Marking every variable and output as
sensitiveso the stored values are redacted - Keeping the state file committed to a private git repository with restricted access
- Always running
terraform applywith the-refresh=falseflag set
Why is the state bucket described as a tier-0 asset?
- It holds plaintext secrets for every environment, so compromising it exposes the credentials for everything Terraform ever provisioned
- It is by far the largest bucket by stored storage volume across the entire project
- It is the only bucket that GCP bills for at a special premium storage rate
- It must stay continuously online and reachable or every Terraform plan and apply across the entire org immediately stops working until the bucket comes back
You got correct