Infrastructure as code has a specific and widely misunderstood secret problem: the state file.
sensitive = true does not encrypt anything
Marking a Terraform variable sensitive = true redacts it from plan and apply output. That is all it does.
The value is stored in plaintext in the state file. Every secret your configuration touches — variables, resource attributes, data source results — is written to terraform.tfstate in the clear.
The consequences follow directly:
- Never commit
terraform.tfstate. Add*.tfstate*to.gitignore. - Use a remote backend with encryption at rest: S3 with SSE-KMS, or HCP Terraform.
- Restrict read access to the state backend as tightly as to the secrets manager itself. Anyone who can read state can read every secret.
- Never post state output in a ticket or a chat channel.
Read secrets rather than pass them
The better pattern is to keep the value out of your configuration entirely by reading it at apply time:
data "aws_secretsmanager_secret_version" "api_key" {
secret_id = "production/api_key"
}
locals {
api_key = jsondecode(
data.aws_secretsmanager_secret_version.api_key.secret_string
)["API_KEY"]
}The value never enters a .tfvars file, never sits in a CI environment variable, and never appears in your shell history. It still lands in state — nothing avoids that — but the number of places it exists drops sharply.
Better still: generate in-provider
The strongest pattern is to have the provider generate the secret so it never transits your machine at all. CloudFormation's GenerateSecretString does this: the stack creates the secret and passes only its ARN to consumers.
ManagedSecret:
Type: AWS::SecretsManager::Secret
Properties:
GenerateSecretString:
PasswordLength: 48
ExcludePunctuation: truePass ARNs and references between resources, not values. A Lambda that receives a secret ARN and resolves it at runtime is strictly better than one that receives the secret as an environment variable.
CloudFormation NoEcho
NoEcho: true hides a parameter from the console, the API and describe-stacks. Like Terraform's sensitive, it is a display control. Parameter values are still recorded in CloudTrail and in the stack's history.
Validation catches the mistake early
The generated variable includes a length validation. A short or empty secret then fails at plan rather than at runtime, which turns a confusing production error into an obvious local one.
Frequently asked questions
Is the state file really plaintext?
Yes. Terraform stores all attribute values, including sensitive ones. sensitive affects console output only. This is documented behaviour and it surprises people every time.
Can I use a local state file if I encrypt the disk?
Better than nothing, but you lose locking, versioning and team access control. Use a remote backend with encryption at rest — it solves several problems at once.
How do I pass a secret into CI without it appearing in logs?
Use the CI system's secret store, which masks values in output, and prefer OIDC-based cloud authentication over static credentials entirely. GitHub Actions, GitLab CI and others can assume an AWS role without any long-lived key.
What about tfvars files?
Never commit one containing secrets. *.tfvars belongs in .gitignore — the gitignore generator includes it along with the state file patterns.