
If you've ever run terraform apply against AWS, GCP, or Azure, your .tfstate file almost certainly contains plaintext secrets right now — database passwords, API keys, TLS private keys — even though you never put a secret directly in any .tf file. Most teams lock down their source code and leave the state file sitting in a world-readable S3 bucket or a local directory that gets backed up to a laptop.
Why state is more sensitive than config
Your .tf files describe intent: "create an RDS instance with this engine version." Terraform's state file, terraform.tfstate, records the actual result, in full detail, including every attribute of every resource it manages. For many resource types, that means Terraform writes the secret value straight into state in plaintext, because it needs to track that value to detect drift and plan future changes. Common examples:
aws_db_instanceandaws_rds_clusterstore the master password in state unless you use a secrets-manager-managed password and specific lifecycle settings.aws_secretsmanager_secret_versionandaws_ssm_parameter(typeSecureString) store the secret value itself in state — the resource exists specifically to hold a secret, and Terraform needs the value to manage it.- Provider credentials passed as resource attributes (API keys for third-party providers like Datadog, PagerDuty, Cloudflare) typically land in state as plaintext.
- Private keys generated by the
tls_private_keyresource are stored in full in state, including the private key material.
Your .tf files might reference a secret via a variable or a data source and never show the raw value. The resulting .tfstate shows it anyway, because Terraform must know the real value to plan and diff correctly.
Step 1 — Get state out of local files
If you're still running terraform apply with a local terraform.tfstate sitting in a repo checkout, that's the first problem: it has no encryption, no access control beyond filesystem permissions, no locking (two people running apply at once can corrupt it), and it's one git add -A away from landing in version control.
Move to a remote backend. For AWS, the standard approach is an S3 bucket plus DynamoDB for locking (or, in newer Terraform versions, S3's native locking support):
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Note encrypt = true here just tells the S3 backend to use server-side encryption on write (SSE-S3 by default) — it's necessary but not sufficient, which is the point of this post. For GCP, use a GCS backend:
terraform {
backend "gcs" {
bucket = "mycompany-terraform-state"
prefix = "prod/network"
}
}
GCS buckets are encrypted at rest by default with Google-managed keys. Azure uses an Azure Storage Account container as its backend equivalent. All three give you durability and locking that a local file never had — but encryption-at-rest with the provider's default keys is the baseline, not the destination.
Step 2 — Encrypt state with a key you control
Default server-side encryption protects against someone stealing the physical disk. It does nothing against someone who has read access to the bucket itself — which, for a shared state bucket, is often more people than you'd like. Use a customer-managed key (CMK) so you can control and audit who can decrypt, and revoke access independently of bucket permissions.
For S3, that means SSE-KMS with a key you manage:
resource "aws_kms_key" "tfstate" {
description = "KMS key for Terraform state encryption"
deletion_window_in_days = 30
enable_key_rotation = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.tfstate.arn
}
bucket_key_enabled = true
}
}
Then reference the key in your backend config so writes go through it:
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
kms_key_id = "arn:aws:kms:us-east-1:111122223333:key/abcd-1234"
encrypt = true
}
}
As of Terraform 1.10+, there is also a native state encryption feature (terraform { encryption { ... } } block) that encrypts the state file's content client-side, independent of backend-level encryption, using a key provider and a method you configure. That's a second, complementary layer: backend-level encryption protects the object at rest in the storage service; Terraform's own state encryption protects the content itself, so even someone with raw access to the backend object sees ciphertext. Check your installed Terraform version before relying on it — it is a relatively new feature and the exact HCL syntax has moved during its early releases.
Step 3 — Lock down who can read the state bucket
Encryption with a shared key that everyone can use to decrypt isn't much better than no encryption. The access control that actually matters is: who can call GetObject on that S3 key, and who can call Decrypt on that KMS key. Write an explicit bucket policy and KMS key policy rather than relying on broad IAM roles:
resource "aws_s3_bucket_policy" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnencryptedTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.tfstate.arn,
"${aws_s3_bucket.tfstate.arn}/*"
]
Condition = {
Bool = { "aws:SecureTransport" = "false" }
}
},
{
Sid = "RestrictToCIRole"
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::111122223333:role/terraform-ci" }
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "${aws_s3_bucket.tfstate.arn}/*"
}
]
})
}
Pair this with block-public-access settings on the bucket (which should be on by default on any bucket created after late 2023, but verify it explicitly), versioning (so an overwritten or deleted state file is recoverable), and a bucket policy that denies any principal outside your CI role or a small admin group. Audit who holds that CI role's trust policy — that's your real blast radius.
Step 4 — Stop secrets from landing in state in the first place
Encrypting state is necessary, but reducing what ends up in state at all is better defense in depth. A few concrete moves:
- Use ephemeral values where supported. Terraform 1.10+ introduced ephemeral resources and the
ephemeralattribute for variables, specifically to let values be used during a plan/apply without being written to state. This is the right long-term fix for secrets that providers support as ephemeral (check your provider's docs — adoption is still growing). - Generate secrets outside Terraform and reference them. Instead of
random_passwordfeeding directly into a resource, have an external secrets manager (AWS Secrets Manager, HashiCorp Vault) generate and store the secret, and have Terraform reference it by ARN/path rather than owning the value. - Use
sensitive = trueon outputs and variables — but understand what it actually does: it only suppresses the value from CLI output (plan/applylogs). It does not remove the value from the state file. This is the single most common misconception about Terraform secrets handling. - Avoid committing
.tfvarsfiles with real secret values to git, even in a "private" repo. Use a.gitignoreentry for*.auto.tfvarsandsecrets.tfvars, and inject secrets via environment variables (TF_VAR_db_password) in CI instead.
Step 5 — Treat state access like production database access
Anyone who can read an unencrypted (or key-shared) state file for a production environment effectively has read access to every secret Terraform manages there, and anyone who can write to it can potentially manipulate Terraform's view of reality — which, in the worst case, enables a state-tampering attack that causes the next apply to do something unexpected (importing a resource you don't own, or quietly adjusting an IAM policy's recorded state). Treat the state bucket's access list with the same rigor as a production database:
- CI/CD pipeline service roles — yes, scoped tightly to their specific state path.
- A small number of break-glass human operators — yes, with logging.
- Everyone else, including most developers who only need to run
terraform planagainst a non-prod workspace — no, or scoped to non-prod state only.
Enable S3 access logging or CloudTrail data events on the bucket so reads are auditable, not just writes.
Common pitfalls
- Thinking
sensitive = trueprotects the state file. It doesn't — it only hides values from terminal output. - Using the same KMS key for everything in the account. A dedicated key for Terraform state lets you scope IAM/key policies tightly and rotate or revoke independently of unrelated workloads.
- Forgetting state files for other workspaces/environments. Teams often lock down prod state carefully and leave staging or dev state world-readable, forgetting staging often has copies of real-ish credentials too.
- Leaving old local
.tfstate.backupfiles around after migrating to a remote backend — these are leftover plaintext copies that nobody remembers to delete. - Checking
.terraform/or*.tfstateinto git by accident. Add them to.gitignorefrom day one, and scan existing repo history if you suspect they were ever committed — a deleted file is still in git history until history is rewritten.
Troubleshooting
- "Error acquiring the state lock" usually means a previous
applywas interrupted (Ctrl+C, CI timeout) without releasing the DynamoDB lock record. Confirm no other apply is actually running, then useterraform force-unlock <lock-id>. - Need to confirm what's actually in state right now: run
terraform showorterraform state pull | lessand grep for fields likepassword,secret,private_keyto see what you're exposing before you lock it down. - Migrating an existing local state to a new encrypted backend: add the backend block, run
terraform init, and Terraform will prompt to migrate existing state; confirm it completes, then securely delete the local.tfstateand any.tfstate.backupfiles. - Suspect state access was broader than it should've been: check CloudTrail (or your cloud's equivalent audit log) for
GetObjectcalls on the state key going back as far as retention allows, and check KMSDecryptcall history for the key.
Where to go next
Once state is encrypted and access is scoped, look at Terraform's native state encryption feature if you're on 1.10+, and consider HashiCorp Vault or your cloud's secrets manager as the source of truth for secret values rather than letting Terraform generate and store them. If you manage many workspaces, standardize the backend/KMS setup in a shared module so every new environment inherits the same posture instead of each one being configured by hand.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


