seekrit
← all posts

Keeping secrets out of Terraform state

How-to

Terraform stores every attribute of every resource it manages in terraform.tfstate, in plaintext. Marking a variable sensitive = true hides it from terraform plan output. It does not hide it from state. If a random_password feeds an aws_db_instance, the password is in the state file, and everyone who can read the state backend can read it.

Since Terraform 1.11 there are two mechanisms that genuinely keep a value out of state, and the pattern for using them looks like this:

ephemeral "seekrit_secret" "db_password" {
  environment_id = seekrit_environment.production.id
  name           = "DATABASE_PASSWORD"
}

resource "aws_db_instance" "app" {
  # ...
  password_wo         = ephemeral.seekrit_secret.db_password.value
  password_wo_version = 1
}

The value is read into memory for one operation, handed to a write-only argument, and discarded. Neither state nor the plan file ever holds it. The rest of this post explains the two mechanisms and the traps.

What does sensitive = true actually do?

It redacts the value in CLI output. That's all. The value is still in the state file, still in the plan file if you save one with -out, and still returned by terraform show -json. This is documented, and it surprises people constantly because "sensitive" sounds like a storage guarantee.

Ephemeral resources: reading a secret without storing it

An ephemeral block (Terraform 1.10+) is like a data source that Terraform promises not to persist. It is evaluated during plan and apply, held in memory, and never written to state or the plan file. Providers that support it expose the values Terraform previously had no safe way to read.

An ephemeral value can flow into three places: another ephemeral resource, a write-only argument, or a provider configuration. It cannot flow into a regular resource attribute, because that would land in state, and Terraform errors rather than letting it.

ephemeral "seekrit_secret" "cloudflare_token" {
  environment_id = seekrit_environment.production.id
  name           = "CLOUDFLARE_API_TOKEN"
}

provider "cloudflare" {
  api_token = ephemeral.seekrit_secret.cloudflare_token.value
}

That's a provider configured from a secret that appears nowhere on disk.

Write-only arguments: writing a secret without storing it

A write-only argument (Terraform 1.11+) is a resource attribute the provider accepts on create and update but never returns, so Terraform never records it. Convention names them with a _wo suffix and pairs each with a _wo_version integer.

The version is the trap. Because Terraform stores nothing, it cannot tell when the value changed. Editing password_wo alone is a no-op plan. Bump password_wo_version whenever the value moves.

Writing a secret into seekrit works the same way:

variable "stripe_key" {
  type      = string
  sensitive = true
  ephemeral = true   # Terraform refuses to persist it anywhere
}

resource "seekrit_secret" "stripe" {
  environment_id   = seekrit_environment.production.id
  name             = "STRIPE_SECRET_KEY"
  value_wo         = var.stripe_key
  value_wo_version = 1
}

ephemeral = true on a variable means Terraform will error if the value tries to reach state through any path. It's the belt to value_wo's braces.

Why no data "seekrit_secret"?

Because a data source's result goes in state. The seekrit provider deliberately has no data source that returns a secret value. Reading is always an ephemeral block. If you are evaluating any secrets provider, this is a quick test: if it offers a data source that returns plaintext, using it defeats the purpose.

Setting up the provider

terraform {
  required_version = ">= 1.11.0"
  required_providers {
    seekrit = {
      source  = "seekritdev/seekrit"
      version = "~> 1.0"
    }
  }
}

provider "seekrit" {
  # endpoint, org_id and token all fall back to SEEKRIT_API_URL,
  # SEEKRIT_ORG and SEEKRIT_TOKEN, so CI needs no values in HCL.
}

The token has to be an admin service token, because the provider creates environments and grants keys:

seekrit token create --admin --name terraform

Treat that token as key material. It can decrypt every environment it has a grant on. Keep it in your CI secret store, scope it to one organization, and rotate it by minting a replacement and revoking the old one.

Writing many secrets with for_each

for_each has to iterate something Terraform can evaluate at plan time, and an ephemeral map isn't that. Split names from values:

variable "secret_names" {
  type    = set(string)
  default = ["DATABASE_URL", "STRIPE_SECRET_KEY", "SENTRY_DSN"]
}

variable "secret_values" {
  type      = map(string)
  sensitive = true
  ephemeral = true
}

resource "seekrit_secret" "app" {
  for_each         = var.secret_names
  environment_id   = seekrit_environment.production.id
  name             = each.key
  value_wo         = var.secret_values[each.key]
  value_wo_version = 1
}

The plan's shape comes from the names. Only the values are secret.

The one value that does go in state

A resource that creates a credential has nowhere else to put it. When Terraform mints a seekrit service token, the API returns the token string once, and seekrit_service_token.token is stored in state so you can output it. This is the same trade aws_iam_access_key.secret makes. Use an encrypted state backend and restrict who can read it. Everything else in the provider stays out of state, and a schema test in the provider's repo fails the build if anyone adds a plaintext attribute.

Checklist

  • Terraform >= 1.11 for write-only arguments, >= 1.10 for ephemeral resources.
  • Read secrets with ephemeral blocks, never data sources.
  • Write secrets through _wo arguments and bump _wo_version on every change.
  • Mark input variables holding secrets ephemeral = true as well as sensitive = true.
  • Encrypt the state backend anyway. Minted credentials still live there.

The provider's full documentation is in the Terraform guide and on the Terraform Registry.