Sensitive Data and Team Workflows
The idea in one sentence
Section titled “The idea in one sentence”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.
What sensitive = true actually does
Section titled “What sensitive = true actually does”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.
terraform state pull | grep -A3 db_connect_string# "db_connect_string": {# "value": "Server=app-db.postgres.database.azure.com;Password=S3cretPassword!",# "type": "string"Protecting state at rest
Section titled “Protecting state at rest”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 planError: 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-onlyattribute 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 planError: 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 ofvalueorvalue_womust be specified.” / “value_wo_version- (Optional) An integer value used to trigger an update forvalue_wo. This property should be incremented when updatingvalue_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,sysd = 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,sysd = 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.
Where shared state stops being enough
Section titled “Where shared state stops being enough”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
applyruns 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
applylocally, 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
Mental Model
Section titled “Mental Model”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.”
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
sensitive = true | Stops 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/resource | Structurally 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 it | Only 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) arguments | The value genuinely never touches state, even transiently — proven above, azurerm_key_vault_secret.value_wo really exists in the pinned provider schema | Opt-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 |
Common Misconceptions
Section titled “Common Misconceptions”- “
sensitive = truekeeps a value out of the state file.” Proven above twice: the originalstate pull | grepexample, and the baselinesensitivityclaim — it only changes CLI display, never storage. - “
ephemeral = trueis just a stricter version ofsensitive = true.” Proven above: they are different mechanisms entirely.sensitiveredacts display but still writes to state;ephemeralis 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 planrefuses with a specific, named reason — the target attribute is “not a write-only attribute and must be persisted to state.” Only a_woattribute 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_secretgenuinely declaresvalue_wo/value_wo_version, whilerandom_passwordin 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.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated code that touches secrets or ephemeral data, check for:
sensitive = truepresented 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
ephemeralvalue referenced directly in a plain (non-_wo) resource attribute, assumed to “just work” because it compiles — proven above,terraform planrejects this with a specific error; it never gets toapply. - A root-module
outputmarkedephemeral = 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 outputsensitive = true— this defeats the one protectionsensitiveprovides for no real gain.- A
value_wo-style argument used without its pairedvalue_wo_versionbump onazurerm_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.idvalue = 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).