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.
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