Originally published on kuryzhev.cloud
Your Terraform state file might already be leaking every Vault secret you've ever provisioned — plaintext, unencrypted, and one terraform state show away from disaster. I've audited enough Terraform vault secrets setups to know this isn't a hypothetical. It's the default behavior, and most teams don't find out until a state file ends up in the wrong S3 bucket or a contractor's laptop. Here are seven things I do differently now, after learning some of these the hard way.
Treat Terraform state as a secret, because it stores Vault data in plaintext
This is the one nobody reads the docs for until it's too late. Resources like vault_kv_secret_v2 and vault_generic_secret write the raw secret value directly into .tfstate — no field-level encryption, no redaction, nothing. If someone can read your state file, they don't need Vault access at all.
Marking an output as sensitive = true only hides it from CLI output and plan diffs — the state file itself still contains the plaintext value. The only real fix is an encrypted remote backend: S3 with SSE-KMS, or Terraform Cloud's managed encrypted storage. And don't stop at bucket policy — lock down IAM so only your pipeline role can read that object.
Authenticate the Vault provider with AppRole, not a root or personal token
I still see provider blocks with token = "s.xxxxxxxx" hardcoded and committed to a private repo. "Private" doesn't mean safe — repos get forked, mirrored, and CI logs get archived longer than anyone expects. Use AppRole auth instead, pulling role_id/secret_id from your CI secrets store or environment variables.
Rotate the secret_id regularly, and constrain the blast radius with secret_id_num_uses and secret_id_ttl on the AppRole itself. Watch out: if you rotate mid-pipeline without updating your CI secret store in the same change, the next terraform plan fails silently with an auth error that looks nothing like a credentials problem.
terraform {
required_version = ">= 1.11" # needed for write-only attribute support
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 4.4" # pin — tested against Vault server 1.16+
}
}
}
provider "vault" {
address = var.vault_addr
# AppRole auth instead of a hardcoded root/personal token
auth_login {
path = "auth/approle/login"
parameters = {
role_id = var.vault_role_id # from CI secret store, not committed
secret_id = var.vault_secret_id
}
}
}
# Correct resource for a KV v2 mount — do NOT use vault_generic_secret here
resource "vault_kv_secret_v2" "app_config" {
mount = "secret" # engine mount path, not the secret path
name = "myapp/config"
delete_all_versions = false # keep version history for audit
data_json = jsonencode({
db_password = var.db_password
api_key = var.api_key
})
}
output "secret_path" {
value = vault_kv_secret_v2.app_config.path
sensitive = true # hides CLI output only — state still has raw value
}
# Dynamic DB credentials with an explicit, short TTL
resource "vault_database_secret_backend_role" "app" {
backend = "database"
name = "app-role"
db_name = "postgres"
creation_statements = ["CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';"]
default_ttl = 3600 # 1h, don't rely on Vault's 768h default
max_ttl = 86400
}
Use ephemeral resources and write-only attributes to stop secrets from ever hitting state
Terraform 1.10 shipped ephemeral resources and values — they get read at apply time and are never persisted to state or plan files. Terraform 1.11 followed with write-only attributes (the _wo suffix): you pass a value in, it's used once, and it's discarded. This is the actual fix for the plaintext-state problem, not a workaround.
Gotcha: not every provider resource supports write-only attributes yet. Check the specific resource's schema in the Vault provider docs before assuming it works — I've seen teams write the ephemeral block correctly, then wire it into a resource attribute that silently falls back to normal (persisted) behavior.
# Terraform 1.10+ ephemeral resource — value read at apply time, never persisted
ephemeral "vault_kv_secret_v2" "runtime_secret" {
mount = "secret"
name = "myapp/runtime"
}
resource "some_app_resource" "example" {
name = "example"
# Terraform 1.11+ write-only attribute: used once, then discarded
api_token_wo = ephemeral.vault_kv_secret_v2.runtime_secret.data["api_token"]
}
# `terraform plan` excerpt — note no secret value shown, and no state entry created:
# # some_app_resource.example will be created
# + name = "example"
# + api_token_wo = (write-only attribute)
#
# Contrast with the OLD pattern below, which leaks the secret into state:
# resource "some_app_resource" "leaky" {
# api_token = vault_kv_secret_v2.app_config.data["api_key"] # ends up in .tfstate plaintext
# }
Don't reuse vault_generic_secret for a KV v2 mount
This is the single most common mistake I run into during Terraform audits. vault_generic_secret was built for KV v1 semantics — no versioning, no metadata. Point it at a KV v2 mount and it will still "work," but it silently mismanages versioning and metadata underneath you.
The symptom is unmistakable once you know what you're looking at: terraform plan shows a perpetual diff on secret metadata fields that never actually resolves, even right after apply. Switch to vault_kv_secret_v2 explicitly, and set delete_all_versions deliberately — leaving it at true permanently wipes version history on destroy, which is exactly what you don't want on anything needing an audit trail.
Give dynamic secrets short TTLs and expect Terraform to regenerate them every apply
If you don't set default_ttl and max_ttl on something like vault_database_secret_backend_role, Vault falls back to its default lease TTL of 768 hours — 32 days. That's a long window for a credential to sit around unrotated, and I stopped relying on Vault defaults after a security review flagged exactly this on a client's Postgres role.
Here's the part that catches people off guard: Terraform doesn't renew Vault leases between applies. Re-running apply can mint a brand-new credential and invalidate the old one mid-deployment, breaking a running service that's still using the previous password. There's also a cost angle — frequent regeneration in high-apply-frequency CI pipelines inflates Vault's audit log volume and API request count, which matters when you're sizing audit storage or hitting Vault server rate limits.
Pin Vault server and provider versions together before upgrading either one
Version skew between Terraform, the Vault provider, and the Vault server itself is an underrated source of "it worked yesterday" incidents. The hashicorp/vault provider v4.x generally targets Vault server 1.15+; if your cluster is still on 1.12 or older, it may be missing KV v2 endpoints that newer resource schemas expect — and the error you get back rarely mentions version compatibility directly.
Pin explicitly: required_version = ">= 1.11" if you're using write-only attributes, and version = "~> 4.4" for the provider. Test any upgrade in a non-prod Vault namespace first — I check the Terraform upgrade guides every time before touching a prod state file, because "minor" provider bumps have broken KV v2 path handling before.
Audit who can run terraform state show and state pull, not just who has Vault policy access
Teams spend weeks tightening Vault policies and then leave state file access wide open. terraform state show vault_kv_secret_v2.app_config prints the secret data in plain text to anyone with CLI access to that state — Vault policy is irrelevant at that point, because the secret already escaped into Terraform's own storage. A leaked state file bypasses Vault entirely.
Also double-check the mount argument matches your actual KV v2 engine path, not the logical secret path — the default is secret/ unless you remounted it, and getting this wrong doesn't error out cleanly, it just creates secrets in the wrong place while plan looks deceptively clean. I keep a short checklist for this in our DevOps notes on kuryzhev.cloud that we run through before every Vault-related module change.
None of these fixes are exotic — they're mostly about not trusting Terraform's defaults with terraform vault secrets management. Pin your versions, use AppRole and short TTLs, and move to ephemeral resources and write-only attributes wherever the provider supports them. The state file is the weak link most teams forget to lock down, and it's usually the easiest one to fix once you know where to look.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.