Taming Sensitive Values and Outputs in Terraform
How Terraform sensitive variables and outputs work, the way sensitivity propagates through expressions, the nonsensitive() footgun, and AI-assisted leak audits.
- #terraform
- #security
- #sensitive
- #outputs
- #ai
The first time it bit me, it was a database password sitting in plain text in a CI job log. Someone had wired a generated RDS password into an output so a downstream module could consume it, forgot the sensitive flag, and terraform plan happily printed the whole thing to a build that a dozen contractors could read. The secret wasn’t even stored anywhere unsafe — it lived in Vault. It leaked because of how it flowed through the config. That distinction is the whole point of this post.
This is not an article about where to keep secrets. Vault, SOPS, AWS Secrets Manager, and SSM Parameter Store all solve the storage problem. This is about what happens after a value enters your Terraform graph: how Terraform tracks sensitivity, how that marking spreads to everything a sensitive value touches, and the handful of ways you can accidentally strip the marking off and dump a credential into a log.
What sensitive = true Actually Does
Marking a variable sensitive is a one-line change with surprisingly broad reach:
variable "db_password" {
type = string
sensitive = true
}
That flag does exactly one thing: it tells Terraform to redact this value from CLI output — plan, apply, and terraform console. It is a display control, not an encryption or access control. The value still flows into providers, still gets written to state, and is still fully usable inside your configuration. It just won’t be echoed to your terminal or your CI logs.
The same applies to outputs, which is where most leaks happen because outputs are explicitly designed to surface values:
output "db_connection_string" {
value = "postgres://admin:${var.db_password}@${aws_db_instance.main.address}:5432/app"
sensitive = true
}
Forget sensitive = true on an output that embeds a secret and Terraform will refuse the plan anyway if the secret came from a sensitive source — which brings us to the most important mechanic.
Sensitivity Propagates Automatically
Here is the part people miss: sensitivity is contagious. Any expression that consumes a sensitive value produces a sensitive result. Concatenate a sensitive password into a connection string, and the whole string becomes sensitive. Run it through jsonencode(), interpolate it, hash a structure that contains it — the output inherits the marking.
locals {
# db_password is sensitive, so connection_string is now sensitive too
connection_string = "postgres://admin:${var.db_password}@db.internal:5432/app"
}
In terraform plan, you’ll see the propagation reflected as masking:
# local.connection_string will be known after apply
+ connection_string = (sensitive value)
Changes to Outputs:
+ db_connection_string = (sensitive value)
That (sensitive value) placeholder is Terraform protecting you. If you reference a sensitive value in an output and don’t mark the output sensitive, Terraform won’t silently leak it — it errors out:
Error: Output refers to sensitive values
To reduce the risk of accidentally exporting sensitive data that was intended
to be only internal, Terraform requires that any root module output containing
sensitive data be explicitly marked as sensitive.
This is genuinely good design. The graph carries the taint with the data, so the failure mode is a loud error at plan time rather than a quiet leak at log time.
Pro Tip: When plan shows (sensitive value) on a resource argument you didn’t expect to be sensitive, trace it backward. It almost always means a sensitive variable or a sensitive resource attribute (like an auto-generated password) is feeding that field. That’s the propagation graph telling you where your secret travels — read it as a map.
nonsensitive() and Why It’s a Footgun
Sometimes propagation is too aggressive. A common case: you derive a non-secret fact from a sensitive value — say, the length of a password for a validation check, or a subnet ID that happens to live in a sensitive variable but isn’t actually secret. Terraform gives you an escape hatch:
output "password_length" {
value = nonsensitive(length(var.db_password))
}
nonsensitive() strips the sensitive marking and tells Terraform “trust me, the result here is safe to display.” And that’s exactly the problem: it is a blanket override of Terraform’s safety mechanism. The function does not inspect what you wrap. If you do this:
# DO NOT DO THIS
output "db_password_plain" {
value = nonsensitive(var.db_password)
}
Terraform will cheerfully print your password to every log that runs the plan. There is no warning beyond the one you’re reading now. nonsensitive() is the single most reliable way to turn a well-protected config into a leaking one, because it silently undoes the contagion that was doing its job.
Rules I hold to: only ever wrap a value that is provably not the secret itself — a length, a boolean, a count, a derived ID you’ve reasoned about. Never wrap the raw value or anything that round-trips back to it. And every nonsensitive() call gets a comment explaining why the result is safe, so the next reviewer doesn’t have to reverse-engineer your intent.
Sensitive Values Still Land in State — Plaintext
Now the uncomfortable truth that sensitive = true does nothing to fix: state files store everything in plain text. Open a terraform.tfstate and your “sensitive” password is sitting right there, unredacted, alongside every other resource attribute. The flag only suppresses terminal output. It has zero effect on what’s persisted.
"attributes": {
"password": "S3cr3t-Pa55w0rd!",
"username": "admin"
}
This is why state handling is a security boundary in its own right. State belongs in an encrypted, access-controlled remote backend — S3 with SSE and a locked-down bucket policy, Terraform Cloud, or equivalent — never in a git repo, never on a laptop, never in a Slack thread. sensitive and secure state are complementary controls, not substitutes. One keeps secrets out of logs; the other keeps them out of the wrong hands at rest.
Using AI to Audit for Leaks
Reading the propagation graph by hand across dozens of modules is exactly the kind of tedious, pattern-matching work that an LLM is good at. I treat a model like Claude or Cursor as a fast junior engineer whose entire job is to flag suspicious data flow before a human reviews it. A useful audit prompt asks the model to:
- Find every
outputthat references a sensitive variable or a known-sensitive resource attribute (passwords, private keys, tokens) and confirm each is markedsensitive = true. - Flag every
nonsensitive()call and explain what’s being unwrapped and whether the result could reconstruct the secret. - Spot secrets interpolated into
local_file,template_file, user-data, ornull_resourceprovisioners — classic side-channel leaks that bypass output redaction entirely. - Catch hard-coded credentials that should be variables in the first place.
The model is fast and thorough at finding candidates, and consistently wrong about a few of them — so this is strictly an advisory pass. The non-negotiable boundaries:
- AI never runs
terraform apply. It proposes; a human reviews the plan and applies. No auto-apply, ever. - The model never touches state files, never gets state-write access, and never receives cloud credentials. Those are the exact assets you’re protecting; handing them to a tool to “help” defeats the purpose.
- A human reviews every plan before it touches infrastructure. The AI shrinks the search space; it does not get the final word.
Pro Tip: Feed the model sanitized HCL and terraform plan text with secrets already redacted — never the raw state JSON. You want it reasoning about the structure of your sensitivity graph, not memorizing your live credentials. A redacted plan shows every (sensitive value) marker, which is exactly the signal an audit needs.
If you want this as a repeatable workflow rather than an ad-hoc chat, a saved prompt or a bundled prompt pack keeps the audit consistent across every config, and routing the diff through an automated code-review pass catches the obvious misses before a human even opens the PR.
Conclusion
Terraform’s sensitivity system is a propagation graph, not a vault. The sensitive flag taints a value and spreads that taint to everything downstream, failing loudly when a secret would otherwise reach an unmarked output. nonsensitive() is the one tool that can quietly undo all of it, so treat every call as a security review. And remember that none of this protects state at rest — that’s a separate, equally important boundary. Lean on AI to audit the flow fast, keep credentials and state far away from the model, and let a human approve every plan. Get those mechanics right and your secrets stay where they belong: out of your logs.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.