skip to content

A Terraform plan fails with "Output refers to sensitive values" on an output that only builds a connection string. What causes this, and what are the two legitimate ways to resolve it?

level: middleimportance: should knowfreq 42%

answer

  1. something upstream is already marked
  2. the mark rides along the expression
  3. whole values, never substrings
  4. explicit acknowledgement or explicit override
  5. nonsensitive() strips it deliberately

basics

~20 s

Sensitivity is contagious in Terraform: a sensitive value interpolated into any expression makes the whole result sensitive, and Terraform refuses to export that silently. Resolve it by marking the output sensitive = true, or by wrapping a genuinely non-revealing derived value in nonsensitive().

solid answer

~50 s

Something in that string is already marked sensitive — a `sensitive = true` variable, or a provider attribute that ships sensitive in its schema such as `random_password.result` or an RDS instance's `password`. Terraform propagates the mark through expressions, so interpolating it into a connection string makes the entire string sensitive, and Terraform will not export a sensitive value from an output without you saying so. The two honest fixes: add `sensitive = true` to the output, which is right when the value really does carry the secret; or, if the derived value provably reveals nothing — a hostname, a port, a set of map keys — wrap it in `nonsensitive()` (Terraform 0.15+) to strip the mark. `nonsensitive()` is an override, not a fix; using it on anything that still contains the secret defeats the whole mechanism.

code

hcl · 18 lines
hcl
variable "user_passwords" {
  type      = map(string)
  sensitive = true
}

# Fails: the string carries a sensitive value.
# output "dsn" { value = "user=${keys(var.user_passwords)[0]} pw=${values(var.user_passwords)[0]}" }

# Fix 1 - acknowledge it.
output "dsn" {
  value     = "user=${keys(var.user_passwords)[0]} pw=${values(var.user_passwords)[0]}"
  sensitive = true
}

# Fix 2 - export only what provably reveals nothing.
output "configured_users" {
  value = nonsensitive(keys(var.user_passwords))
}

go deeper

for a junior

Recognise the error message and know the first move: something in that expression is already sensitive, so add sensitive = true to the output rather than trying to work around it.

for a middle

Explain contagion precisely — the mark rides on the value through interpolation and functions, and applies to the whole result, never a substring — and name nonsensitive() as the deliberate, narrow escape.

for a senior

Judge the two fixes in review. Say when nonsensitive() is defensible, insist on the argument for why the derived value is safe, and push back on round-tripping tricks that only appear to strip the mark.

for a principal

Question whether the secret belongs in the configuration's data flow at all. Prefer exporting a reference — a secret ID or ARN the consumer resolves at runtime — so contagion never arises and no pipeline ever holds the plaintext.

## The error and what it is telling you Terraform stops the plan with roughly: ``` 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, to confirm your intent. ``` This is not a bug and not a false positive. It means: *the expression you are exporting is carrying a sensitive value, and you have not acknowledged that.* ## Where the mark came from You rarely apply the mark to the string yourself. It arrives from one of three places: 1. **A sensitive input variable** — `variable "db_password" { sensitive = true }`. 2. **A provider schema attribute that is inherently secret.** Providers declare these; `random_password.result` and a database instance's `password` argument are the canonical examples. You get the mark for free, whether or not you wanted it. 3. **A sensitive output of a child module** you are consuming. ## Contagion: the actual mechanism Terraform tracks sensitivity as a *mark on a value*, not as a property of the resource attribute it came from. When a marked value participates in an expression, the mark propagates to the result. Concatenating it into a string, putting it in a list or map, passing it through `join()`, `format()`, `jsonencode()`, or nesting it in an object all produce a sensitive result. Marks are coarse deliberately: Terraform makes the *whole* containing value sensitive rather than trying to redact a substring, because tracking a secret through a rendered string is not something a type system can do reliably. This coarseness is the point. Without it, the trivial workaround — build a string around the secret and export that — would defeat every redaction. With it, the secret cannot escape by being wrapped, and the error above is Terraform refusing to let it escape silently. The practical side effect: an output you consider entirely boring can suddenly demand `sensitive = true` because one field somewhere upstream is marked, and the mark travelled. The same coarseness bites elsewhere too — a sensitive value cannot be used where Terraform needs a value at plan time to build the graph, such as a `for_each` key or a resource `count`, because the diff would reveal it. ## Fix one: mark the output sensitive ```hcl output "connection_string" { value = "postgres://${var.db_user}:${var.db_password}@${aws_db_instance.main.address}:5432/app" sensitive = true } ``` This is correct whenever the exported value genuinely embeds the secret. It is the default answer, and it is what an interviewer wants to hear first. The consequence is that the value is redacted in plan, apply and plain `terraform output` — and still sits in state in clear text and still comes out of `terraform output -json`, which is a separate concern. ## Fix two: `nonsensitive()` `nonsensitive()`, available since Terraform 0.15, strips the mark from a value: ```hcl # The password is sensitive; the SET OF USERNAMES is not. output "configured_users" { value = nonsensitive(keys(var.user_passwords)) } ``` Use it only when you can argue that the derived value reveals nothing about the secret. Legitimate cases are narrow and usually structural: the *keys* of a map whose values are secret, a hostname or port pulled out of an object that also contains a credential, a boolean like `length(var.password) > 0`. `nonsensitive()` errors if the value it is given is not sensitive at all, which stops it from being sprinkled defensively. Using `nonsensitive()` on something that still contains the secret is not a fix — it is disabling the safety mechanism, and it should be an obvious red flag in review. The tell is `nonsensitive()` wrapped around a value the reviewer cannot prove is safe. ## Related escape hatches, and why they are worse Engineers sometimes reach for tricks: pushing the value through `jsonencode()`/`jsondecode()`, writing it into a `local` first, or passing it through a `terraform_data` resource. Marks survive most of these, and where a version happens to lose one, relying on it is relying on a leak. There is one honest question worth asking instead: does the secret need to be an output at all? Frequently the caller does not need the value, only a reference to where the value lives — a secret ID or ARN — which is not sensitive and which the consuming system resolves at runtime. ## The answer in one breath A sensitive value got into the expression, sensitivity propagates through expressions, and Terraform will not export it without explicit acknowledgement. Mark the output sensitive when it truly carries the secret; use `nonsensitive()` only for a derived value that provably does not.

  • When is nonsensitive() defensible, and when is it a red flag?
    Defensible when the derived value provably cannot reveal the secret — the key set of a map with secret values, a hostname or port extracted from an object that also carries a credential, a length or presence check. It is a red flag whenever a reviewer cannot make that argument, because it then simply disables Terraform's only guard against exporting the secret.
  • Why can't a sensitive value be used as a for_each key or a count?
    Terraform needs those values at plan time to build resource addresses, and addresses appear in the plan, the diff, and state keys — so using a sensitive value there would print the secret in every plan. Terraform rejects it outright. Derive the keys from a non-sensitive identifier and look the secret up by that key instead.

saying these in an interview costs you the question

  • Thinks the error is a bug and looks for a flag to disable it
  • Wraps the value in nonsensitive() without checking what it contains
  • Believes jsonencode/jsondecode round-tripping legitimately strips sensitivity
  • Assumes only the secret substring is redacted, not the whole string
  • Says marking the output sensitive also removes it from state

context