Skip to content

Sensitive Data and Team Workflows

Marking a value sensitive = true hides it from your terminal, but the value is still sitting in plaintext in the state file, so the real fix is protecting the state backend itself and keeping long-lived secrets out of Terraform entirely.

Both variable and output blocks accept a sensitive argument:

variable "database_password" {
type = string
sensitive = true
}
output "db_connect_string" {
value = "Server=${azurerm_postgresql_flexible_server.main.fqdn};Password=${var.database_password}"
sensitive = true
}

With sensitive = true set, Terraform redacts the value everywhere it would otherwise show up on your terminal or in a CI log: it prints (sensitive value) in terraform plan and terraform apply output instead of the real string, and it refuses to print the raw value from terraform output unless you explicitly ask with terraform output db_connect_string. That is a genuinely useful guardrail — it stops a password from scrolling past in a shared CI log or a screen-shared terminal by accident.

Here is the misconception worth being explicit about: sensitive = true is a display feature, not an encryption feature. The value is computed, stored, and diffed by Terraform exactly the same way whether or not you mark it sensitive — it still gets written into terraform.tfstate in full plaintext, tags and all. Anyone who can read the raw state file, whether from an Azure Storage container, a local .tfstate, or a terraform state pull, can see that password sitting right there in JSON, sensitive flag or no sensitive flag.

Terminal window
terraform state pull | grep -A3 db_connect_string
# "db_connect_string": {
# "value": "Server=app-db.postgres.database.azure.com;Password=S3cretPassword!",
# "type": "string"

Since marking a value sensitive does not remove it from state, the actual protection has to happen at the storage layer. Treat terraform.tfstate itself as a secret, the same way you would treat a .env file full of production credentials:

  • Encryption at rest is already on by default. Azure Storage encrypts every object at rest automatically, including every version of your state blob, regardless of who accesses the storage account API. You do not need to turn this on — it is the baseline, not the whole answer.
  • Restrict who can read the container. Azure RBAC is what actually stops an unauthorized reader from ever reaching the plaintext, encryption at rest notwithstanding — encryption at rest protects against someone getting at the raw disk, not against someone with a valid Azure identity calling the storage API. Scope access tightly with a role like Storage Blob Data Reader, assigned only at the resource group or storage account level that holds state, not subscription-wide.
resource "azurerm_role_assignment" "state_reader" {
scope = azurerm_storage_account.tfstate.id
role_definition_name = "Storage Blob Data Reader"
principal_id = data.azuread_group.platform_team.object_id
}

Where possible, the better move is avoiding the problem entirely: do not put long-lived secrets into Terraform variables in the first place if you can pull them at apply time instead. A data source that reads from Azure Key Vault fetches the current value when Terraform runs, without you ever typing the secret into a .tf or .tfvars file:

data "azurerm_key_vault_secret" "db_password" {
name = "app-db-password"
key_vault_id = data.azurerm_key_vault.main.id
}
resource "azurerm_postgresql_flexible_server" "main" {
name = "app-db"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
version = "15"
sku_name = "GP_Standard_D2s_v3"
administrator_login = "appadmin"
administrator_password = data.azurerm_key_vault_secret.db_password.value
}

Be clear-eyed about what this does and does not solve: the resolved secret value still ends up in azurerm_postgresql_flexible_server.main’s state entry, because Terraform has to record the attribute it set, exactly like the plaintext risk from the first lesson in this module. What it does solve is the secret’s lifecycle everywhere else — it is never typed into a variable, never sits in a .tfvars file that might get committed by accident, and rotating it in Key Vault does not require touching any Terraform configuration. Protecting the state backend itself is still the non-negotiable part.

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 azurerm provider even ships a matching ephemeral resource for reading a secret straight out of Key Vault without ever recording it: ephemeral "azurerm_key_vault_secret" "db" { name = "app-db-password"; key_vault_id = data.azurerm_key_vault.main.id } — a real, documented resource (developer.hashicorp.com/terraform/language/manage-sensitive-data/ephemeral), not a hypothetical. This course has no live Key Vault 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 azurerm: azurerm_key_vault_secret genuinely declares the pair, per the real provider docs (registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault_secret):

“value_wo - (Optional, Write-Only) Specifies the value of the Key Vault Secret. … One of value or value_wo must be specified.” / “value_wo_version - (Optional) An integer value used to trigger an update for value_wo. This property should be incremented when updating value_wo.”

Confirmed directly against the pinned hashicorp/azurerm 5.7.0 provider schema — no Azure credentials needed, terraform providers schema -json only reads what init already downloaded:

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

What genuinely does need real credentials is applying it — creating a real Key Vault Secret object needs a live Key Vault and an authenticated Azure identity. 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 azurerm_key_vault_secret.value_wo has 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.

The previous lesson set up an azurerm backend with built-in locking, which solves the “everyone reads and writes the same state safely” problem. That is necessary, but it is not sufficient once a team grows past a couple of engineers. With nothing else in place, “everyone runs terraform apply from their own laptop whenever they feel like it” still has real gaps:

  • Concurrent apply runs do not corrupt anything now that locking is in place, but they do queue up behind each other — a second engineer’s run just waits, with no visibility into what the first run is doing or how long it will take.
  • There is no natural point where a second person reviews a change before it goes live. Locking prevents two applies from racing; it does not stop one engineer from applying a change nobody else has looked at.
  • Each engineer needs valid Azure credentials with enough permission to run apply locally, which is a wider blast radius than most teams want to hand out to every laptop.

None of that means the shared backend from the previous lesson was wrong — it is a prerequisite, not a mistake. It just is not the whole story. Closing this gap is exactly what CI/CD for infrastructure is for: a pipeline runs plan on every proposed change for review before anyone applies it, and apply runs from a controlled pipeline identity instead of individual laptops. That is a topic for later in this course, not something to solve here — the point for now is recognizing the shape of the gap so it does not come as a surprise.

flowchart LR
  sv["variable with sensitive = true"] -->|redacted| cli["CLI output: (sensitive value)"]
  sv -->|full value written| state["terraform.tfstate: plaintext"]
  kv["Azure Key Vault"] -->|data source at apply time| res["Resource attribute"]
  res -->|attribute recorded| state
A sensitive variable is redacted from CLI output but still lands in state as plaintext, versus a secret pulled from Key Vault at apply time

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, azurerm_key_vault_secret.value_wo really exists 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); azurerm even ships ephemeral "azurerm_key_vault_secret" 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, azurerm_key_vault_secret.value_wo really exists in the pinned provider schemaOpt-in per resource per provider, not a blanket 1.11 guarantee — proven above, random_password has none at all; real use needs a live Key Vault and Azure 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 | grep example, and the baseline sensitivity claim — 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: azurerm_key_vault_secret genuinely declares value_wo/value_wo_version, 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 value_wo-style argument used without its paired value_wo_version bump on azurerm_key_vault_secret, 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 "azurerm_key_vault_secret" "db" {
name = "app-db-password"
key_vault_id = data.azurerm_key_vault.main.id
value = ephemeral.random_password.db.result
}
Show the answer

Proven above with the real error for the same mistake on terraform_data.marker: value on azurerm_key_vault_secret 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: value_wo = ephemeral.random_password.db.result plus a value_wo_version that increments whenever the value should change. Writing to the plain value argument would not even get as far as reaching Azure — 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 setting sensitive = true on a Terraform variable actually protect against
A database password is marked sensitive = true on its variable block. Is it still readable in plaintext anywhere
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 azurerm_key_vault_secret schema really declares value_wo, but the pinned random_password schema declares zero write-only attributes. What does this prove?
$ terraform providers schema -json | ... azurerm_key_vault_secret ...
value_wo {'write_only': True} value_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 sensitive data stored in a Terraform state file on Azure