Skip to content

Sensitive Data and Team Workflows

Marking a variable or output sensitive = true hides its value from CLI output, but the value is still stored in plaintext inside the state file, so protecting state itself is a separate, and more important, problem.

You can mark a variable or an output as sensitive:

variable "db_password" {
type = string
sensitive = true
}
output "db_password" {
value = var.db_password
sensitive = true
}

With this in place, terraform plan and terraform apply print (sensitive value) instead of the real password in the terminal, and it is redacted from CI logs the same way. This is a genuinely useful feature — it stops a secret from ending up in a build log that a dozen people can read, or on a screen during a pairing session.

It is also frequently misunderstood. sensitive = true is a display-redaction feature, not an encryption feature. The actual value is still written into terraform.tfstate in plaintext, exactly as covered in the first lesson of this module. Anyone who can read the raw state file can read the password regardless of how many resources or variables were marked sensitive along the way.

Since redaction does not protect the file at rest, protection has to happen at the storage layer instead:

  • Rely on Cloud Storage’s server-side encryption for the state bucket. GCS encrypts objects at rest by default, and you can layer a customer-managed Cloud KMS key on top via the backend’s kms_encryption_key argument if your organization requires it.
  • Restrict who can read the bucket with IAM, scoped as tightly as possible. Grant roles/storage.objectViewer on the specific state bucket to the specific principals who need it — a CI service account, a small platform team — rather than granting broad access at the project level. Treat the state file itself as a secret, because for practical purposes it is one.

Where possible, avoid putting long-lived secrets into Terraform configuration or state at all. Instead of a variable holding a raw password, pull the value in at apply time from a secrets manager:

data "google_secret_manager_secret_version" "db_password" {
secret = "db-password"
}
resource "google_sql_user" "app" {
name = "app_admin"
instance = google_sql_database_instance.main.name
password = data.google_secret_manager_secret_version.db_password.secret_data
}

This does not make the value vanish from state entirely — the resource attribute Terraform sets still gets recorded the same way it always does. What it does change is where the secret’s long-term source of truth lives: Secret Manager, with its own access policies, audit logging, and rotation story, instead of a Terraform variable that someone has to remember to pass in securely every single run.

nonsensitive(): deliberately un-redacting a value

Section titled “nonsensitive(): deliberately un-redacting a value”

Sometimes sensitive = true propagates further than you want — any expression that touches a sensitive value becomes sensitive too, even if the result genuinely is not secret. The nonsensitive() function strips that marking from a single value, deliberately, for exactly that case. Real proof with terraform console, first showing the redaction, then removing it:

$ echo 'var.db_password' | terraform console
(sensitive value)
$ echo 'nonsensitive(var.db_password)' | terraform console
"S3cretPassword!"

That is the entire mechanism: nonsensitive() does not add any protection and does not remove the value from state either way — it only changes whether Terraform’s redaction applies to this particular expression’s result going forward. Reach for it only when you are certain the value is not actually secret in this context; calling it on an actual secret just to quiet a warning defeats the one guardrail sensitive = true provides.

Ephemeral values: excluded from state and plan by construction

Section titled “Ephemeral values: excluded from state and plan by construction”

Terraform 1.10 added ephemeral resources, variables, and outputs — values that exist only for the duration of a single Terraform operation and are never written to state or a saved plan file, not “written but redacted” the way sensitive = true values are. Mark a variable ephemeral like this:

variable "session_token" {
type = string
ephemeral = true
}

The google provider ships a matching ephemeral resource for reading a secret straight out of Secret Manager without ever recording it, documented with a real, complete example (registry.terraform.io/providers/hashicorp/google/latest/docs/ephemeral-resources/secret_manager_secret_version): ephemeral "google_secret_manager_secret_version" "example" { project = "my-project"; secret = "my-secret"; version = "latest" }, whose .secret_data is then routed into another resource’s write-only argument. This course has no live Secret Manager secret to read from, so the offline proof below uses the built-in pattern instead — a short-lived value generated fresh on every run, never persisted anywhere:

ephemeral "random_password" "db" {
length = 16
}

Because an ephemeral value is never persisted, Terraform enforces where it is allowed to flow, at plan time, with a real error — not just a convention. Proof: passing ephemeral.random_password.db.result into an ordinary (non-write-only) resource argument:

$ terraform plan
Error: Invalid use of ephemeral value
with terraform_data.marker,
on main.tf line 15, in resource "terraform_data" "marker":
15: input = ephemeral.random_password.db.result
Ephemeral values are not valid for "input", because it is not a write-only
attribute and must be persisted to state.

That error names the actual rule directly: an ephemeral value may only flow into an attribute the provider has explicitly declared write-only, because a write-only attribute is the one kind of argument Terraform guarantees it will never persist. A second real constraint, proven the same way: an ephemeral output is not allowed at all in the root module —

$ terraform plan
Error: Ephemeral outputs are not allowed in context of a root module

— ephemeral outputs exist so a child module can hand an ephemeral value back up to whatever called it (to be consumed by that caller’s own write-only argument or further ephemeral chain), not so the root module can print one.

Write-only arguments (_wo): the other half of the mechanism

Section titled “Write-only arguments (_wo): the other half of the mechanism”

Terraform 1.11 added write-only arguments — resource attributes, conventionally suffixed _wo and paired with a companion _wo_version integer, that a provider promises never to write into state or a saved plan at all. This is not a hypothetical for the google provider: google_secret_manager_secret_version genuinely declares the pair, per the real provider docs — “secret_data_wo - (Optional, Write-Only) The secret data. … Note: This property is write-only and will not be read from the API.” / “secret_data_wo_version - (Optional) Triggers update of secret_data_wo write-only. Increment this value when an update to secret_data_wo is needed.” — and the resource’s own top-level note confirms it directly: “All arguments marked as write-only values will not be stored in the state: secret_data_wo.”

Confirmed directly against the pinned hashicorp/google 8.4.0 provider schema — no GCP credentials needed, terraform providers schema -json only reads what init already downloaded, and this same check found 7 resources across the whole provider with a write-only attribute (google_secret_manager_secret_version, google_sql_database_instance, google_sql_user, google_alloydb_user, google_compute_ssl_certificate, google_compute_region_ssl_certificate, google_compute_vpn_tunnel):

$ terraform providers schema -json | python3 -c "
import json,sys
d = json.load(sys.stdin)
attrs = d['provider_schemas']['registry.terraform.io/hashicorp/google']['resource_schemas']['google_secret_manager_secret_version']['block']['attributes']
for k, v in attrs.items():
if v.get('write_only') or k.endswith('_wo_version'):
print(k, v)
"
secret_data_wo {'type': 'string', 'description_kind': 'plain', 'optional': True, 'write_only': True}
secret_data_wo_version {'type': 'number', 'description_kind': 'plain', 'optional': True}

What genuinely does need real credentials is applying it — creating a real Secret Manager secret version needs a live GCP project. Here is the honest question instead: is write-only support universal across every resource, or opt-in? Checking hashicorp/random’s random_password — a resource this course can fully apply offline — with the identical query:

$ terraform providers schema -json | python3 -c "
import json,sys
d = json.load(sys.stdin)
attrs = d['provider_schemas']['registry.terraform.io/hashicorp/random']['resource_schemas']['random_password']['block']['attributes']
print([k for k,v in attrs.items() if v.get('write_only')])
"
[]

Zero. random_password has no write-only attribute at all, and neither does the built-in terraform_data. The real picture, proven both ways above: write-only support is not a Terraform-wide guarantee every resource gets for free at 1.11 — a provider author adds it one resource, one attribute at a time, and as of these pinned versions exactly 7 google resources have it while none of this course’s fully-offline-appliable resources do. What is fully provable offline end-to-end, proven above, is the other half of the mechanism that makes write-only arguments useful in the first place: the ephemeral-value enforcement that decides what is even allowed to flow into a _wo argument once a resource has one.

Earlier lessons in this module got state onto a shared GCS backend with built-in locking, which already fixes the worst team problems — divergent local files and corrupted concurrent writes. But it does not fully solve how a growing team should work together. If everyone still runs terraform apply from their own laptop whenever they feel like it, concurrent applies simply queue up behind the lock instead of colliding, and there is still no natural point where a second person reviews a change before it takes effect against real infrastructure.

That gap — a reviewable, auditable place for changes to be approved before apply runs against shared state — is exactly what CI/CD for infrastructure closes, and it is covered later in this course.

flowchart LR
  varInput["variable db_password (sensitive = true)"] -->|redacted in CLI output| cli["terraform plan / apply output"]
  varInput -->|value still written| state["terraform.tfstate (plaintext)"]
  secretManager["Secret Manager secret version"] -->|read via data source at apply time| resource["google_sql_user.app"]
  resource --> state
A sensitive variable still lands in state as plaintext, while a data source pulls a secret at apply time instead of storing it as a variable

Three mechanisms in this lesson look similar from a distance — sensitive, ephemeral, and write-only arguments — but they solve genuinely different problems, and the difference is exactly what gets written to state. sensitive = true writes the value to state in full, and only redacts your terminal — proven above, the raw value is one state pull | grep away. ephemeral values are never written to state at all, and Terraform enforces that at plan time with a real error the moment you try to route one into an ordinary attribute — proven above. Write-only arguments are the provider-side half of that same guarantee: a _wo attribute is the one place an ephemeral value is allowed to land, specifically because the provider has promised never to persist it — proven above, google_secret_manager_secret_version.secret_data_wo (and six sibling resources across the provider) really exist in the pinned schema. Read in that order — display-only, structurally-excluded, provider-promised-never-persisted — the three stop looking like three flavors of “secret” and start looking like three different answers to “does this reach disk.”

ChoiceBenefitCost
sensitive = trueStops a value scrolling past in a shared terminal or CI log (proven above: (sensitive value))Zero effect on state — proven above, the raw value is still one state pull away
ephemeral variable/resourceStructurally cannot reach state or a saved plan — enforced by a real error, not a convention (proven above); google even ships ephemeral "google_secret_manager_secret_version" to read a secret without ever recording itOnly usable in a narrow set of contexts (write-only arguments, provider config, other ephemeral values); a root-module ephemeral output is rejected outright (proven above)
Write-only (_wo) argumentsThe value genuinely never touches state, even transiently — proven above, 7 real google resources declare oneOpt-in per resource per provider, not a blanket 1.11 guarantee — proven above, random_password has none at all; real use needs a live GCP project and credentials
nonsensitive()Lets a value that is not actually secret escape unnecessary redaction (proven above via console)Removes the one guardrail sensitive = true provides if misapplied to something that actually is secret
  • “sensitive = true keeps a value out of the state file.” Proven above twice: the original state pull claim from earlier in this module, and the baseline redaction example — it only changes CLI display, never storage.
  • “ephemeral = true is just a stricter version of sensitive = true.” Proven above: they are different mechanisms entirely. sensitive redacts display but still writes to state; ephemeral is structurally barred from state and plan, enforced by a real “Ephemeral values are not valid for …” error the moment you try to persist one.
  • “Any resource can accept an ephemeral value if you just reference it directly.” Proven above: terraform plan refuses with a specific, named reason — the target attribute is “not a write-only attribute and must be persisted to state.” Only a _wo attribute a provider has explicitly built qualifies.
  • “Write-only arguments are a general Terraform 1.11 feature you can use with any resource.” Proven above by dumping two real provider schemas: google_secret_manager_secret_version genuinely declares secret_data_wo/secret_data_wo_version (one of 7 google resources that do), while random_password in the exact same pinned toolchain declares zero write-only attributes — it is opt-in, per resource, per provider, not a capability every resource gets for free at 1.11.

When reviewing generated code that touches secrets or ephemeral data, check for:

  • sensitive = true presented as “the fix” for a credential, with no mention of the backend still holding it in plaintext — proven above, that is exactly the gap this lesson closes.
  • An ephemeral value referenced directly in a plain (non-_wo) resource attribute, assumed to “just work” because it compiles — proven above, terraform plan rejects this with a specific error; it never gets to apply.
  • A root-module output marked ephemeral = true, intended to hand a generated secret to something outside Terraform — proven above, this is rejected outright; ephemeral outputs only exist for a child module handing a value back up to its caller.
  • nonsensitive() wrapped around a value that genuinely is secret, just to silence a “sensitive value in output” error, instead of marking the output sensitive = true — this defeats the one protection sensitive provides for no real gain.
  • A secret_data_wo-style argument used without its paired secret_data_wo_version bump on google_secret_manager_secret_version, then a report that “the new secret value never gets applied” — proven above, the version number is the only signal Terraform has that a new write-only value needs sending, since it never stores the value itself to diff against.

🐛 Spot the bug

ephemeral "random_password" "db" {
length = 16
}
resource "google_secret_manager_secret_version" "db" {
secret = google_secret_manager_secret.db.id
secret_data = ephemeral.random_password.db.result
}
Show the answer

Proven above with the real error for the same mistake on terraform_data.marker: secret_data on google_secret_manager_secret_version is an ordinary, persisted attribute, not a write-only one — Terraform rejects this plan with “Ephemeral values are not valid for …, because it is not a write-only attribute and must be persisted to state.” The fix is not to give up on generating the secret ephemerally; it is to route it through the resource’s actual write-only pair instead: secret_data_wo = ephemeral.random_password.db.result plus a secret_data_wo_version that increments whenever the value should change. Writing to the plain secret_data argument would not even get as far as reaching GCP — the plan itself fails first, which is the safety net working as designed, not a bug to route around with sensitive = true (that would only redact display; the ephemeral value still could not be persisted to a non-write-only attribute).

What does marking a variable sensitive = true actually protect against
What does this real error prove about where an ephemeral value is allowed to go?
$ terraform plan
Error: Invalid use of ephemeral value

  with terraform_data.marker,
  ...
  15:   input = ephemeral.random_password.db.result

Ephemeral values are not valid for "input", because it is not a write-only
attribute and must be persisted to state.
The pinned google_secret_manager_secret_version schema really declares secret_data_wo, but the pinned random_password schema declares zero write-only attributes. What does this prove?
$ terraform providers schema -json | ... google_secret_manager_secret_version ...
secret_data_wo {'write_only': True} secret_data_wo_version {...}
$ terraform providers schema -json | ... random_password ...
[]
Why does a root-module output marked ephemeral = true fail with "Ephemeral outputs are not allowed in context of a root module"
What is the real way to protect a GCS-backed state bucket at rest
A team already has a shared GCS backend with built-in locking. What gap still remains for a growing team