Secrets in IaC leak through three doors: plan output, plaintext state, and static credentials in code. This episode combines client-side state encryption with sensitive variables, then pulls dynamic secrets from Vault, AWS Secrets Manager, and GCP Secret Manager during apply.

In the previous episode 14 we reviewed managed platforms — Spacelift, env0, Scalr — and Digger. All those platforms run OpenTofu, but none of them can save you when secrets leak through things they treat as normal: state files, plan output, or sloppily written variables. Infrastructure security isn't determined by the platform, but by how secrets are stored, encrypted, and delivered to the engine.
In episode 15 we harden two layers at once. First, we combine client-side state encryption — first covered in episode 6 — with sensitive variables so secrets don't appear in plan or output. Second, we replace static credentials in code with dynamic secrets pulled directly during tofu apply from HashiCorp Vault, AWS Secrets Manager, and GCP Secret Manager.
Writing a password directly in variables.tf is a habit that must stop from day one. The right way: make that variable sensitive = true, so its value is never shown in tofu plan or tofu output, and never appears when rendered to screen:
variable "db_password" {
type = string
sensitive = true
description = "Database password, supplied from a secret manager during apply"
}
output "db_connection_string" {
value = "${var.db_username}@${var.db_endpoint}"
sensitive = true
}Sensitive values make OpenTofu replace their contents with a red arrow in the plan output. Only one caveat to remember: sensitive = true only masks the display — the real value still rests intact in state.
Caution
Never put secret values in committed .tfvars files, because their trace lives forever in git history. In CI, supply sensitive variables via environment secrets or a .tfvars file generated by the pipeline and discarded after use.
That layer only controls display. The next layer is storage: state contains the real values of every resource, including passwords and keys. In episode 6 we already saw OpenTofu's solution — client-side state encryption, which encrypts state on the client before sending it to the backend, so even an S3 admin can't read its contents:
encryption {
key_provider "aws_kms" "main" {
kms_key_id = "arn:aws:kms:ap-southeast-1:123456789012:key/example-key-id"
}
method "aes_gcm" "main" {
keys = key_provider.aws_kms.main
}
state {
method = method.aes_gcm.main
}
}Combine both: sensitive = true keeps secrets from appearing in output and plan, while encryption keeps the same secrets from being readable in state. These are two sides of the same security door.
Static credentials inside .tf files — tokens or keys — are always a risk: copied to laptops, leaked to git, or lodging in CI caches. The solution is pulling secrets at apply time rather than storing them. The Vault provider reads secrets from HashiCorp Vault directly when OpenTofu evaluates the expression:
variable "vault_token" {
type = string
sensitive = true
}
provider "vault" {
address = var.vault_addr
token = var.vault_token
}
data "vault_generic_secret" "db" {
path = "secret/data/db/prod"
}
resource "aws_db_instance" "postgres" {
engine = "postgres"
username = data.vault_generic_secret.db.data["username"]
password = data.vault_generic_secret.db.data["password"]
}Notice the pattern: secrets are never stored as literals in code. OpenTofu reads the values from Vault during apply, and those values never appear in git history.
When the whole stack already runs on one cloud, using the cloud's built-in secret manager reduces the components you must manage. On AWS, the aws_secretsmanager_secret_version data source fetches the latest value of a secret, and the value can be a JSON blob you decode:
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/db-credentials"
}
resource "aws_db_instance" "postgres" {
engine = "postgres"
username = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["username"]
password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]
}On GCP, google_secret_manager_secret_version fetches the active secret version and its value is available through the secret_data attribute:
data "google_secret_manager_secret_version" "api_key" {
secret = "api-key"
}
resource "google_cloudfunctions2_function" "api" {
environment_variables = {
API_KEY = data.google_secret_manager_secret_version.api_key.secret_data
}
}| Secret source | When it fits | OpenTofu data source |
|---|---|---|
| HashiCorp Vault | Multi-cloud, active rotation, strict policy | vault_generic_secret |
| AWS Secrets Manager | Full stack on AWS | aws_secretsmanager_secret_version |
| GCP Secret Manager | Full stack on GCP | google_secret_manager_secret_version |
Tip
Rotating secrets in a secret manager requires no code changes at all. When the value changes in Vault or AWS, just re-run tofu plan then tofu apply — the data source reads the latest version and dependent resources update automatically.
sensitive = true on variables and outputs; each has its own leak path.plan.tfplan files to public artifacts — a plan stores the same secret values as state.tofu console and tofu plan -no-color can display sensitive values — be careful when capturing logs.In episode 15 we hardened secret handling:
sensitive = true on variables and outputs to stop leaks in plan and output.vault_generic_secret during apply.aws_secretsmanager_secret_version and google_secret_manager_secret_version for cloud-native approaches.Securing secrets is one thing; ensuring what may and may not be created in the cloud is another. In the next episode, episode 16, we'll build Policy as Code with Open Policy Agent: extracting the plan JSON with tofu show -json and writing Rego rules that reject security groups with port 22 open to the public or storage without encryption. See you there!