Secrets leak in three distinct places in a Terraform workflow, and each needs its own fix. Applying one fix and assuming the others are covered is the most common security mistake in the whole tool.
Topic 1: The Three Leaks
| Leak | Fix |
|---|---|
| Configuration files — hard-coded credentials committed to version control | Secret manager data source, or environment variables |
| CLI output and logs — values printed at plan/apply, captured by CI log storage | sensitive = true |
| State — every sensitive attribute, plain text, regardless of markers | Encrypt the backend and restrict who can read it |
The third is the one people miss, and it is worth stating flatly: marking something sensitive does not keep it out of state.
Topic 2: Keeping Secrets Out of Configuration
Never hard-code. Provider credentials belong in environment variables or the provider’s own credential chain:
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
export ARM_CLIENT_ID="..."
export ARM_CLIENT_SECRET="..."
export TF_VAR_db_password="..."
A provider block containing literal keys is the single most common finding in a Terraform security review, and it is usually copied from a tutorial.
Fetch from a secret manager at apply time — the strongest pattern, because the value never exists in your code or in a variable file:
data "aws_ssm_parameter" "db_password" {
name = "/prod/db/password"
with_decryption = true
}
resource "aws_db_instance" "app" {
username = var.db_username
password = data.aws_ssm_parameter.db_password.value
}
Equivalents exist for every major secret store — dedicated secret-management platforms, cloud-native secret managers, and key vaults all expose data sources.
The honest caveat: the fetched value still lands in state. What you have removed is the copy in your repository and the need for a human to handle it — which is most of the risk, but not all of it.
Topic 3: sensitive = true, Precisely
variable "db_password" {
type = string
sensitive = true
}
output "db_endpoint" {
value = aws_db_instance.app.endpoint
sensitive = true
}
This redacts the value from CLI output and from logs. In a pipeline that matters a great deal — CI logs are often retained for months and readable by more people than production.
What it does not do: remove the value from state, or prevent terraform output -json from emitting it, or stop a provider writing it into a resource attribute somewhere else.
Topic 4: Protecting State
Because state holds secrets in plain text, the backend is a production-secrets store whether you designed it as one or not.
terraform {
backend "s3" {
bucket = "org-terraform-state-prod"
key = "platform/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true # server-side encryption
}
}
- Encrypt at rest.
encrypt = true, or the equivalent on your backend. Managed offerings encrypt unconditionally. - Encrypt in transit. Backends use HTTPS endpoints; state travels over TLS.
- Restrict access with IAM — scoped to exactly the state prefix and lock table the pipeline needs:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
"Resource": "arn:aws:s3:::org-terraform-state-prod/*"
},
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
"Resource": "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/terraform-state-locks"
}
]
}
Anyone with read access to the state bucket can read every secret in every environment it manages. Treat that permission as equivalent to production admin, because it is.
Topic 5: What Never Enters Version Control
# .gitignore
*.tfstate
*.tfstate.*
*.tfvars # except example files
.terraform/
.terraform.lock.hcl # commit this one, actually — see below
*.pem
terraform.tfstate— contains every secret.*.tfvars— routinely contains passwords and account identifiers. Committerraform.tfvars.examplewith placeholder values instead.- Private keys. The file without
.pub, ever, with permissions set to0400locally. .terraform.lock.hclis the exception — commit it. It pins provider checksums so every run resolves identical binaries.
.terraformignore excludes files from upload when using a remote-execution backend:
*.tfstate
*.log
.secret/*
Topic 6: Least Privilege and Audit
Scope the pipeline role to what its stack needs. A network pipeline does not need database permissions. This is tedious to set up and is the control that limits blast radius when something else fails.
Role-based access over who may plan and who may apply. Enterprise offerings provide this natively with team segmentation; self-managed setups implement it through cloud IAM plus pipeline permissions.
Audit logging so every API call Terraform makes is attributable:
resource "aws_cloudtrail" "audit" {
name = "terraform-audit"
s3_bucket_name = aws_s3_bucket.audit_logs.id
include_global_service_events = true
is_multi_region_trail = true
enable_logging = true
}
Attribution is why a shared credential is a problem: when every apply is the same service account, the audit log tells you what happened but never who caused it.
Try it yourself: Grep your state file for the word password. On most real projects something turns up. That result is the reason every recommendation in this lesson exists.
Common mistake: Marking a variable sensitive and considering the secret handled. The terminal is now clean and the state file is unchanged — you have fixed the least important of the three leaks and may have stopped looking for the other two.