Terraform "Invalid for_each argument": sensitive values cannot be used as for_each keys
After marking a variable sensitive to keep database passwords out of plan output, terraform plan failed with Invalid for_each argument. Sensitive values cannot be for_each keys because keys appear in resource addresses. How we restructured the input so keys stay public and secrets stay hidden.
A security review of our Terraform plans found database passwords in clear text in CI logs. The values came from a map variable that described database users, including their initial passwords, and the plan printed them when creating PostgreSQL roles. The obvious fix was to mark the variable as sensitive.
variable "db_users" {
type = map(object({
password = string
privileges = list(string)
}))
sensitive = true
}
The next plan failed:
Error: Invalid for_each argument
on users.tf line 1, in resource "postgresql_role" "app":
1: resource "postgresql_role" "app" {
2: for_each = var.db_users
Sensitive values, or values derived from sensitive values, cannot be used as for_each arguments. If
used, the sensitive value could be exposed as a resource instance key.
The resource that used it
resource "postgresql_role" "app" {
for_each = var.db_users
name = each.key
login = true
password = each.value.password
}
resource "postgresql_grant" "app" {
for_each = var.db_users
role = postgresql_role.app[each.key].name
database = "orders"
object_type = "table"
schema = "public"
privileges = each.value.privileges
}
What we ruled out
- A provider bug. The error comes from Terraform core during expression evaluation, before any provider call.
- The
sensitiveflag on the password attribute alone. The provider already markedpasswordas a sensitive attribute in the resource schema, which hides it in plan output for that resource. The leak was elsewhere: anoutputand alocalthat echoed the whole map in a debug step.
Root cause
Marking the whole map as sensitive made every value derived from it sensitive, including its keys. The
keys of a for_each map become part of resource instance addresses, such as
postgresql_role.app["reporting"], which Terraform prints in plans, state and logs. Terraform refuses to
use sensitive values as keys because doing so would print them. The design mixed public identifiers
(user names) and secrets (passwords) in one value, so the only way to hide one was to hide both.
How sensitivity propagates
Terraform tracks sensitivity as a mark on values. Any expression that uses a sensitive value produces a
sensitive result: keys(var.db_users), var.db_users["reporting"].privileges and a for expression over
the map are all sensitive. That is why the error message says "or values derived from sensitive values".
The mark protects you from accidentally printing a secret through some indirect path.
Why keys must be public
Resource instances keyed by for_each are identified by their keys everywhere: plan output, state,
terraform state list, error messages and the CLI. There is no way to hide a key and still show which
instance is being created, changed or destroyed. So the rule is absolute: keys must be known and not
sensitive.
Environment
| Component | Version / value |
|---|---|
| Terraform | 1.12 |
| Provider | cyrilgdn/postgresql 1.25 |
| Input | db_users map passed from a CI secret as JSON |
| CI | GitHub Actions, plan output stored as a job log |
Who is affected
Any configuration that marks a collection as sensitive and then iterates over it with for_each, or
derives keys from a sensitive value, for example toset(keys(var.secrets)). It also appears when a
module output marked sensitive = true is used as for_each in a calling module, and when values come
from a data source that the provider marks as sensitive, such as secret manager lookups. The rule has
been enforced since Terraform 0.15; older configurations that relied on implicit behaviour started
failing on upgrade.
Separate public structure from secrets
We split the input into two variables: one public map that defines which users exist and their privileges, and one sensitive map with passwords, keyed by the same names.
variable "db_users" {
description = "Database users and their privileges. Not secret."
type = map(object({
privileges = list(string)
}))
}
variable "db_user_passwords" {
description = "Initial passwords keyed by user name."
type = map(string)
sensitive = true
}
resource "postgresql_role" "app" {
for_each = var.db_users # public keys and values
name = each.key
login = true
password = var.db_user_passwords[each.key] # sensitive lookup, allowed in an argument
}
resource "postgresql_grant" "app" {
for_each = var.db_users
role = postgresql_role.app[each.key].name
database = "orders"
object_type = "table"
schema = "public"
privileges = each.value.privileges
}
Using a sensitive value as a resource argument is fine; Terraform redacts it in the plan. Only the
for_each collection and its keys must be non-sensitive.
A precondition catches a missing password early, without revealing anything:
resource "postgresql_role" "app" {
# ...
lifecycle {
precondition {
condition = contains(keys(var.db_user_passwords), each.key)
error_message = "Missing password for database user ${each.key}."
}
}
}
Better still: do not pass passwords through Terraform
Passwords in variables end up in state. We moved to generating them inside Terraform and storing them in a secrets manager, so no human or CI variable ever holds them:
resource "random_password" "db" {
for_each = var.db_users
length = 32
special = false
}
resource "aws_secretsmanager_secret" "db" {
for_each = var.db_users
name = "orders/db/${each.key}"
}
resource "aws_secretsmanager_secret_version" "db" {
for_each = var.db_users
secret_id = aws_secretsmanager_secret.db[each.key].id
secret_string = random_password.db[each.key].result
}
The values are still in state, which is why the state bucket is encrypted, versioned and restricted to the pipeline role. Terraform 1.10 and later also support ephemeral values and write-only arguments in some providers, which keep secrets out of state entirely; we plan to adopt them as provider support grows.
About nonsensitive()
nonsensitive() removes the sensitive mark, and the commonly suggested fix is:
for_each = nonsensitive(toset(keys(var.db_users)))
That is acceptable only when you are sure the keys are not secret, as with user names here, and it
silences a protection meant for you. We prefer separating the inputs, so the code itself says what is
secret, and we flag nonsensitive in review.
Alternatives and trade-offs
- Keep one map, use
nonsensitive(keys(...)). Quick, but anyone later adding a secret key would get no warning. - Store user definitions in a non-secret file (YAML in the repository) and passwords only in the secrets manager. This is effectively what we did.
How to test the fix
terraform plan -out=tfplan
terraform show tfplan | grep -E '^\s+\+ password'
# + password = (sensitive value)
terraform show -json tfplan | jq '.resource_changes[] | select(.type=="postgresql_role") | .change.after_sensitive.password'
# true
Confirming in production
The plan for the orders database listed roles by name with password = (sensitive value). A scan of the
last 30 CI logs with a secret detector, configured with the known old passwords, found no matches after
the change. All existing passwords were rotated anyway, since they had appeared in older logs.
Regression guard
- A CI step fails if
terraform show -jsonof any plan contains a value matching our password patterns outsidesensitivefields. - Code review checklist: secrets in their own sensitive variables;
nonsensitiveneeds a comment explaining why. - Debug outputs that print whole variables were removed and banned by a lint rule.
Security implications
The original leak exposed database credentials to anyone with access to CI logs, so rotation was mandatory. The fix limits exposure in plan output, but secrets remain in Terraform state when passed or generated through it. Protect state as a secret store: encryption, restricted access, versioning and no local copies on laptops.
Prevention checklist
- Keep identifiers and secrets in separate inputs.
- Use only non-sensitive collections for
for_each. - Avoid
nonsensitiveunless the value is provably not secret, and document why. - Generate secrets in Terraform or a secrets manager instead of passing them in.
- Treat Terraform state as sensitive data.
References
- Terraform for_each meta-argument: https://developer.hashicorp.com/terraform/language/meta-arguments/for_each
- Sensitive input variables: https://developer.hashicorp.com/terraform/language/values/variables#suppressing-values-in-cli-output
- nonsensitive function: https://developer.hashicorp.com/terraform/language/functions/nonsensitive
- Ephemeral values in Terraform: https://developer.hashicorp.com/terraform/language/resources/ephemeral
Add a label
Embed this post
<iframe src="https://www.hibug.me/terraform-invalid-for-each-sensitive-value" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a DevOps engineer who has spent years turning hand-built infrastructure into Terraform, and hand-run deployments into pipelines. I work mostly on AWS, with Jenkins, GitHub Actions and a lot of Docker images. Infrastructure code fails differently from application code: state gets locked, a plan wants to destroy something important, a network route that looks right sends nothing. I write up the problems and the migrations that taught me something, step by step, including the parts that went wrong, so you can plan your own changes with fewer surprises.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.