DevOps & CI/CD Bug Report

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.

Isabel Ferreira Isabel Ferreira @isabelferreira · May 30, 2025 · 7 min read · 40 Views
Banner: Terraform Invalid for_each argument, sensitive values cannot be used as for_each keys

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 sensitive flag on the password attribute alone. The provider already marked password as a sensitive attribute in the resource schema, which hides it in plan output for that resource. The leak was elsewhere: an output and a local that 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 -json of any plan contains a value matching our password patterns outside sensitive fields.
  • Code review checklist: secrets in their own sensitive variables; nonsensitive needs 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 nonsensitive unless 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

Add a label

#terraform #secrets #for-each #HCL #sensitive-values
Written by
Isabel Ferreira @isabelferreira

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.