Managing Secrets in Terraform
Terraform has a secrets problem. Database passwords, API keys, TLS certificates, service account credentials — infrastructure code is full of sensitive values. And Terraform stores every one of them in plaintext in the state file.
This isn't a bug. It's how Terraform was designed. The state file is a JSON document that records the current configuration of every managed resource, including any sensitive attributes. Anyone with access to the state file can read every secret Terraform has ever managed.
This guide walks through where secrets leak, what teams do about it, and how to architect your Terraform deployment to keep secrets out of the wrong hands.
The scope of the problem
The original issue — storing sensitive values in state files — was opened in 2014. A formal proposal for state encryption followed in 2016. Neither has been implemented. OpenTofu shipped state encryption as a core feature. Terraform still stores state as plaintext JSON.
This means every secret that Terraform manages — every random_password, every tls_private_key, every database connection string — is readable by anyone who can access the state file. And that's just the beginning.
Where secrets leak
1. State files
The most obvious surface. A Terraform state file for a typical production environment might contain:
{
"type": "random_password",
"name": "db_admin",
"instances": [{
"attributes": {
"result": "V3ry$ecretP@ssw0rd!"
}
}]
}
That password is in plaintext. So is every other sensitive attribute in every resource Terraform manages. The state file is the single largest concentration of secrets in most infrastructure setups, and it's stored in an S3 bucket, an Azure Storage Account, or a GCS bucket that your entire team can read.
There have been proposals to store secrets in a separate, more secure backend and to minimise secrets in state entirely, but neither has been implemented.
2. Plan output
Terraform 0.14 introduced the sensitive flag to suppress values in plan output. In theory, this prevents secrets from appearing in logs and CI output. In practice, the implementation creates as many problems as it solves:
- Over-suppression. Using a sensitive variable in a
local-execprovisioner suppresses the entire command output, not just the sensitive part. You lose all debugging visibility. - Object tainting. A variable with one sensitive field makes the full object sensitive. An object with
{name: "mydb", password: "secret"}hides both values, making plan review meaningless. - No operator escape hatch. Operators can't view sensitive values in state even when they need to debug. There's no concept of "this user is authorised to see secrets."
- Dynamic block breakage. Sensitive values can't be used in
dynamicblockfor_each, forcing workarounds withnonsensitive()— which defeats the purpose. - Hidden plan details. Since Terraform 1.4, entire resource block plan details are hidden when any attribute is sensitive, making it impossible to review what's actually changing.
The net result: sensitive helps with accidental log exposure but actively hinders plan review — the single most important safety mechanism for infrastructure changes.
3. Cross-state exposure
When teams split infrastructure into multiple states, they typically use terraform_remote_state to share outputs. This data source reads the entire state file, not just the outputs — giving consumers access to every secret in the producer's state. Even outputs explicitly marked as sensitive are readable.
This means a team that should only see a VPC ID gets access to every database password, API key, and private key in the networking state. The access model is all-or-nothing.
4. CI pipelines and PR comments
CI-driven Terraform workflows typically post plan output to pull requests for review. That plan output can contain sensitive values — connection strings, ARNs with embedded credentials, provider-specific attributes that weren't marked sensitive. Atlantis users have flagged this as a persistent problem. Any CI tool that captures Terraform output faces the same risk.
5. Disk
Terraform writes the state file to disk during operations. Plan files (.tfplan) can contain sensitive values. Provider plugins may cache credentials. Terragrunt can't inject secrets without writing them to disk — generate blocks and .auto.tfvars files are written to the working directory before terraform runs.
In CI environments, these files persist on shared runners. On developer machines, they persist in working directories. Neither location is designed for secret storage.
6. Ephemeral values (OpenTofu)
OpenTofu introduced ephemeral values — variables and outputs that are never written to state. This is a significant improvement, but the implementation has gaps: ephemeral values are blocked at destroy-time, creating a situation where infrastructure can be created using ephemeral secrets but can't be destroyed without them.
Common approaches
Environment variables
The simplest approach: pass secrets as environment variables.
export TF_VAR_db_password="V3ry$ecretP@ssw0rd!"
terraform apply
Advantages: secrets don't appear in code. Easy to integrate with CI secret stores.
Problems:
- No scoping. Every variable is available to the entire Terraform run — there's no way to restrict a secret to a specific resource or module.
- Secrets still end up in state. The variable value is used to configure a resource, and the resource's attributes (including the secret) are written to the state file.
- Process environment visibility. On shared CI runners, environment variables may be visible to other processes or logged by the runner agent.
External secret stores via data sources
Read secrets from Vault, AWS Secrets Manager, or Azure Key Vault at plan time:
data "vault_generic_secret" "db" {
path = "secret/data/production/database"
}
resource "aws_db_instance" "main" {
password = data.vault_generic_secret.db.data["password"]
}
Advantages: secrets are managed in a purpose-built system with access control, rotation, and audit logging.
Problems:
- Secrets still end up in state. The
data.vault_generic_secret.db.data["password"]value is resolved at plan time and written to state. You've moved where secrets are stored but not where they end up. - Terraform needs access to the secret store. The machine running Terraform needs credentials to read from Vault/Secrets Manager/Key Vault. Those credentials are themselves secrets that need managing.
- Plan-time resolution. Secrets are fetched during
terraform plan, which means the plan file contains them. Anyone who can read the plan can read the secrets.
Backend encryption
Most remote state backends support encryption at rest:
- S3: Server-side encryption with KMS.
- Azure Blob: Storage account encryption with customer-managed keys.
- GCS: Customer-managed encryption keys.
This protects the state file at rest in the storage backend. But every terraform plan and terraform apply downloads the full state file and decrypts it locally. The secrets are in plaintext on the machine running Terraform, in the plan file, and in memory. Encryption at rest protects against storage-layer breaches, not against anyone who can run terraform state pull.
Terraform's sensitive flag
Marking variables and outputs as sensitive prevents their values from appearing in plan output:
variable "db_password" {
type = string
sensitive = true
}
output "connection_string" {
value = "postgresql://admin:${var.db_password}@${aws_db_instance.main.endpoint}/mydb"
sensitive = true
}
This helps with log exposure but has the UX issues described above — over-suppression, object tainting, and no authorised-user escape hatch. And it doesn't affect what's stored in state. The value is still there in plaintext; it's just hidden from terraform plan output.
OpenTofu state encryption
OpenTofu's state encryption is the most complete solution available in the open-source Terraform ecosystem. It encrypts the state file before writing it to the backend, so the data at rest is genuinely unreadable without the encryption key:
terraform {
encryption {
key_provider "aws_kms" "main" {
kms_key_id = "arn:aws:kms:us-east-1:123456789:key/abcd-1234"
region = "us-east-1"
}
method "aes_gcm" "main" {
keys = key_provider.aws_kms.main
}
state {
method = method.aes_gcm.main
}
}
}
This is a genuine improvement, but it's OpenTofu-specific. Teams using HashiCorp Terraform don't have this option.
How Snap CD handles secrets
Snap CD doesn't eliminate the fundamental problem — Terraform still writes sensitive attributes to state during execution. What Snap CD does is reduce the surface area: secrets are scoped narrowly, stored encrypted, and injected at the orchestration layer rather than embedded in Terraform code.
Scoped secrets
Secrets in Snap CD are scoped to the level where they're needed — Stack, Namespace, or individual Module. You create secrets via the Snap CD dashboard or API, then bind them to Module inputs using the Terraform provider:
resource "snapcd_module_input_from_secret" "db_password" {
module_id = snapcd_module.database.id
name = "db_password"
secret_id = data.snapcd_module_secret.db_password.id
input_kind = "Param"
}
The database module receives db_password as a Terraform variable. The networking module never sees it. The compute module never sees it. Each secret is available only to the module that needs it — not to every module in the organisation.
This solves the scoping problem that Pulumi users have been requesting — secrets scoped at the project level rather than globally available. Snap CD goes further by scoping to the individual module.
Encrypted storage with server-side resolution
Secrets are encrypted at rest in Snap CD's secret store — either AES-256-GCM encryption in the database (self-hosted) or Azure Key Vault (paid tier). When a runner needs to execute a plan or apply, the server resolves the secret values, decrypts them, and sends them to the runner over an authenticated connection. The runner injects them as environment variables or writes them to a .tfvars file for the Terraform process.
The secrets are still plaintext on the runner during execution — this is unavoidable given how Terraform works. But they're scoped to a single module's run, written to ephemeral files that are cleaned up after the job completes, and never committed to the runner's disk permanently.
Azure Key Vault as a secret backend
Snap CD supports Azure Key Vault as its secret storage backend. Instead of using the default database-encrypted store, secrets are stored directly in Key Vault — giving you Key Vault's access policies, audit logging, and rotation capabilities for the secrets that Snap CD manages.
This is a backend configuration choice, not a per-secret integration. All secrets for the organisation are stored in the configured backend.
Encrypted sensitive outputs
When a Snap CD module produces outputs marked as sensitive, those values are encrypted in Snap CD's secret store. Downstream modules that consume those outputs receive the decrypted value at runtime, but it's never stored in plaintext on the server.
RBAC for plan output
Snap CD's permission system controls who can view plan output. A Reader can see that a plan exists and whether it was approved. A Contributor can see the full plan output, including changes to sensitive resources. An Owner can approve applies.
This is the authorised-user escape hatch that Terraform's sensitive flag lacks. Instead of hiding secrets from everyone or showing them to everyone, you control visibility through roles.
What Snap CD doesn't solve
Terraform writes resource attributes — including sensitive ones — to the state file during apply. A random_password resource's result attribute will be in the state regardless of how the password was injected. Snap CD controls how secrets get into the Terraform run, but it can't control what Terraform writes to state during execution.
The practical mitigations: keep secret-bearing resources in small, tightly scoped modules with restricted access. Use Snap CD's RBAC to limit who can view plan output. And where possible, use external secret stores directly (e.g., generate passwords in Key Vault and pass only references to Terraform) to keep the actual secret values out of Terraform's scope entirely.
Comparison
| Environment variables | Data source + Vault | Backend encryption | OpenTofu encryption | Snap CD | |
|---|---|---|---|---|---|
| Secrets in state | Yes | Yes | Encrypted at rest | Encrypted | Yes (Terraform still writes to state) |
| Scoped per module | No | No | No | No | Yes |
| Plan output control | sensitive flag |
sensitive flag |
sensitive flag |
sensitive flag |
RBAC-controlled |
| External store integration | Manual | Via data sources | N/A | N/A | Azure Key Vault as backend |
| Cross-state leakage | Via terraform_remote_state |
Via terraform_remote_state |
Via terraform_remote_state |
Via terraform_remote_state |
Encrypted output wiring |
| Disk exposure | .tfvars, env |
Plan files, state | Plan files | Plan files | Runner-only, ephemeral |
Tips
- Audit your state files. Run
terraform state pull | grep -i passwordon your production states. You'll likely find secrets you didn't know were there. - Don't rely on
sensitivealone. It hides values from plan output but doesn't remove them from state. It's a cosmetic measure, not a security control. - Separate secret-bearing resources. If you can't avoid secrets in state, at least keep them in a small state with tight access control. A 500-resource state with database passwords is 500 resources worth of blast radius around your secrets.
- Rotate secrets independently of infrastructure changes. If rotating a database password requires
terraform apply, you're coupling secret rotation to infrastructure deployment. Use external secret stores where possible. - Monitor state file access. Enable access logging on your state backend (S3 access logs, Azure Storage analytics, GCS audit logs). Know who's reading your state files.
- Consider OpenTofu for state encryption. If you're not locked into HashiCorp Terraform, OpenTofu's state encryption is the most complete open-source solution for protecting secrets at rest.
See also
- Self-Hosted Terraform Runners with Credential Isolation — how Runners scope credentials per Module
- Modular Deployments — how secrets fit into the broader Module and input system
- The Problem with Large Terraform States — why smaller states reduce secret exposure
- A Permission System Built for Infrastructure — RBAC for controlling who can view plan output and approve applies