Terraform state files store secret values in plaintext and are often a source of leaks. Learn how to secure state with KMS encryption + strict IAM, as well as secret integration from Vault, AWS Secrets Manager, and SOPS.

After discussing Managed IaC Platforms like Terraform Cloud and Spacelift in episode 14, which provide remote runners, state management, and policy enforcement — in this episode we'll touch on the topic that most often keeps DevOps teams up at night: secret management and state file security.
Imagine this scenario: a new engineer on your team follows a tutorial on the internet and runs terraform apply on their laptop. A few months later, another team discovers that the terraform.tfstate file got committed to the repository because the .gitignore didn't exist yet. In that JSON file, the production RDS database password, IAM credentials, and private keys are printed out in raw form. The repository might have been private at the time, but privilege escalation from read access, a departing contractor, or a repo fork could open up the entire production environment in minutes.
This story isn't fiction. In episode 5 we already touched on how state files store sensitive data in plaintext — now we'll seriously discuss why that happens, how to secure the storage, and how secrets should be managed inside Terraform so they're never written in code or state.
The source of the problem is simple: the state file stores every attribute of the resources Terraform manages, including secret attributes. When an aws_db_instance resource is created with a password argument, that password value is copied as-is into terraform.tfstate in plain JSON format without encryption.
{
"version": 4,
"terraform_version": "1.9.5",
"resources": [
{
"mode": "managed",
"type": "aws_db_instance",
"name": "app",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"id": "db-XXXXXXXXXXXXX",
"address": "app.ckzxxxxxxxx.ap-southeast-1.rds.amazonaws.com",
"username": "appadmin",
"password": "S3ns1t1f-P4ssw0rd-RDS#2026",
"port": 5432,
"engine": "postgres"
}
}
]
}
]
}Note the "password": "S3ns1t1f-P4ssw0rd-RDS#2026" line — that's an example of leaking data. It's not just RDS: IAM credentials (aws_iam_access_key), private keys (aws_key_pair), provider tokens — all of it can be in here.
Warning
sensitive = true does NOT protect state. Marking a variable as sensitive only hides its value from terminal display and logs while terraform plan/apply is running. The actual value is still written in plaintext in the state file. So treat the state file as equivalent to a collection of production secrets — it must be handled that strictly.
There's one more thing that's often forgotten: the plan file. When you run terraform plan -out=tfplan, the plan result also stores sensitive values in a binary format that anyone with read access can decrypt using terraform show. tfplan must be treated just as carefully as state.
Since state is the most valuable asset, its security strategy is layered. On AWS, the layers are:
| Layer | Control | Function |
|---|---|---|
| 1 | Private bucket + block_public_access | Bucket can't be publicly accessed |
| 2 | At-rest encryption (SSE-KMS) | Bucket contents (state) encrypted even when taken by the storage provider |
| 3 | Bucket versioning | Overwritten or corrupted state can be rolled back |
| 4 | Strict IAM + MFA | Only specific roles can read/write the state bucket |
| 5 | State locking (DynamoDB) | Prevents concurrent execution from corrupting state |
The following backend configuration uses a dedicated KMS key for state encryption:
terraform {
backend "s3" {
bucket = "myapp-tfstate-bucket"
key = "production/rds/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
kms_key_id = "arn:aws:kms:ap-southeast-1:123456789012:key/mykmskey"
dynamodb_table = "myapp-tfstate-lock"
}
}Note the kms_key_id attribute: all state stored in this bucket is encrypted by the team's own KMS key — not AWS's default key. With enable_key_rotation = true on the KMS key, the layered encryption rotates automatically.
Here's the complete state bucket definition along with its policy. The state bucket is usually bootstrapped by hand (or via one separate Terraform directory) because it's the foundation of everything:
resource "aws_s3_bucket" "terraform_state" {
bucket = "myapp-tfstate-bucket"
force_destroy = false
tags = {
Name = "Terraform State"
Environment = "Security"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
kms_master_key_id = aws_kms_key.terraform_state.arn
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_kms_key" "terraform_state" {
description = "KMS key for state file encryption"
enable_key_rotation = true
deletion_window_in_days = 30
}Encryption alone isn't enough — we also have to limit who can read and write state. The principle is least privilege: only the roles running Terraform (CI/CD or senior engineers) may touch this bucket. Add a bucket policy that forces all access through HTTPS:
data "aws_iam_policy_document" "tfstate_policy" {
statement {
sid = "AllowTerraformRunners"
effect = "Allow"
principals {
type = "AWS"
identifiers = var.tf_runner_roles # ARN of CI/CD role + approved engineers
}
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
]
resources = [
aws_s3_bucket.terraform_state.arn,
"${aws_s3_bucket.terraform_state.arn}/*",
]
}
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
principals { type = "*" }
actions = ["s3:*"]
resources = [
aws_s3_bucket.terraform_state.arn,
"${aws_s3_bucket.terraform_state.arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
policy = data.aws_iam_policy_document.tfstate_policy.json
}Important
The golden principle: anyone who can read state can read all production secrets. Never give s3:GetObject access to the state bucket to all engineers by default. Give access only to CI/CD runners (via OIDC) and a few engineers who genuinely handle infrastructure, then consider MFA delete for destructive operations.
For GCP, the principle is the same: use the backend "gcs" with a bucket that enables Object Versioning, and restrict access via a dedicated IAM role. Encryption in GCS is enabled automatically (CSEK/CMEK if needed).
The second layer of secret security is the secret source itself. Many teams write passwords directly in variables.tf with default = "rahasia123" or in terraform.tfvars which turns out to get committed. The rules you must hold onto:
default) or in committed files..gitignore .tfvars and state files from day one.# State — never goes into git
*.tfstate
*.tfstate.backup
*.tfstate.lock.info
# Per-environment secret values
*.tfvars
*.tfvars.json
.terraform/
# But keep example templates committed
!example.tfvarsNow we'll look at three popular ways to pull secrets from outside the code: HashiCorp Vault, AWS Secrets Manager, and SOPS.
Vault is the most flexible secret store in the multi-cloud ecosystem. Terraform pulls secrets through a data source, so the values never exist in the HCL code:
provider "vault" {
address = "https://vault.internal.example.com:8200"
token = var.vault_token
}
data "vault_kv_secret_v2" "database" {
mount = "secret"
name = "production/database"
}
resource "aws_db_instance" "app" {
engine = "postgres"
instance_class = "db.t4g.medium"
username = data.vault_kv_secret_v2.database.data["username"]
password = data.vault_kv_secret_v2.database.data["password"]
vpc_security_group_ids = [var.app_security_group_id]
db_subnet_group_name = var.db_subnet_group_name
skip_final_snapshot = true
}Vault's strengths: it supports dynamic secrets (e.g. database credentials that rotate automatically), leases, and modern auth methods like Kubernetes ServiceAccount or AWS IAM. The username/password values still end up in state, but the source never leaks in Git.
For teams that are entirely on AWS, Secrets Manager is the easiest choice because it's natively integrated and supports AWS-managed automatic rotation:
data "aws_secretsmanager_secret" "database" {
name = "production/database"
}
data "aws_secretsmanager_secret_version" "database" {
secret_id = data.aws_secretsmanager_secret.database.id
}
locals {
db_secrets = jsondecode(data.aws_secretsmanager_secret_version.database.secret_string)
}
resource "aws_db_instance" "app" {
engine = "postgres"
instance_class = "db.t4g.medium"
username = local.db_secrets["username"]
password = local.db_secrets["password"]
vpc_security_group_ids = [var.app_security_group_id]
db_subnet_group_name = var.db_subnet_group_name
skip_final_snapshot = true
}Since Secrets Manager stores secrets as JSON strings, we use jsondecode(...) then pull the fields via local.db_secrets. Access to the secret is controlled by IAM policy — so make sure the role running Terraform is actually allowed to read the required secret.
Sometimes teams don't want to depend on additional secret manager infrastructure. SOPS (Secrets OPerationS) from Mozilla lets us store encrypted .tfvars files in Git, with keys managed via age/PGP/KMS. Encryption is done per-field, so Git only sees ciphertext.
creation_rules:
- path_regex: \.tfvars$
age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx# Edit the encrypted file (editor opens, auto-encrypts on save)
sops terraform.tfvars
# Or encrypt an existing plaintext file
sops --encrypt --in-place terraform.tfvars
# Decrypt without writing to disk, then pipe to Terraform
sops --decrypt terraform.tfvars | terraform plan -var-file=-
sops --decrypt terraform.tfvars | terraform apply -var-file=-Tip
SOPS's value lies in a repository containing encrypted files that can be reviewed and audited, without requiring an additional secret store service. It's suitable for small teams or environments that don't have Vault infrastructure yet. The age/pgp/KMS keys themselves must not be in the repo — put them in CI/CD secrets or ~/.config/sops/age/ locally with chmod 600.
Let's compare the three approaches above in one view:
provider "vault" {
address = "https://vault.internal.example.com:8200"
}
data "vault_kv_secret_v2" "db" {
mount = "secret"
name = "production/database"
}
resource "aws_db_instance" "app" {
username = data.vault_kv_secret_v2.db.data["username"]
password = data.vault_kv_secret_v2.db.data["password"]
}| Secret Source | Terraform Integration | Managed Infra? | Automatic Rotation | Best Suited For |
|---|---|---|---|---|
| HashiCorp Vault | vault_kv_secret_v2 data source | Needs its own infra | Yes (dynamic secrets) | Multi-cloud, workload identity, enterprise needs |
| AWS Secrets Manager | aws_secretsmanager_secret_version data source | AWS | Yes (via Lambda) | All-AWS resources, teams without extra infra |
| SOPS | Encrypted .tfvars file in Git | Not needed | Manual (re-encrypt) | Repo-centric, small teams, no secret store |
sensitive = trueEven though sensitive = true isn't protection for state, it's still mandatory to use. Its function: prevent secret values from appearing in terminal output, CI/CD logs, and plan artifacts. Let's look at the practice.
First, mark the variables that contain secrets:
variable "db_username" {
type = string
description = "Database connection username."
sensitive = true
}
variable "db_password" {
type = string
description = "Database connection password."
sensitive = true
}
variable "db_host" {
type = string
description = "Database host address (not a secret)."
default = "localhost"
}Second, mark sensitive outputs:
output "db_endpoint" {
description = "Production database endpoint."
value = aws_db_instance.app.endpoint
sensitive = true
}When terraform apply is run, this value is hidden from the terminal:
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Outputs:
db_endpoint = <sensitive>Even though it's not visible, the value is still stored in state and can be read with terraform output -json by those who have access. So remember: sensitive = true is a visual safety screen, not encryption.
Caution
Be careful with terraform plan -out=tfplan. The binary-format plan file stores sensitive values, and terraform show tfplan will display them — including values marked sensitive. Never commit a tfplan file, and make sure this file only exists on a secure CI/CD runner (usually already in .gitignore).
| Mistake | Symptom | Solution |
|---|---|---|
| State committed to Git | Plaintext password leaks into commit history | .gitignore + treat all secrets as leaked → full rotation + clean history (git filter-repo) |
Hardcoding secrets in variable default | Secret visible in code reviewed by everyone | Move to Vault/Secrets Manager/SOPS |
.tfvars gets committed | Environment credentials leak | .gitignore *.tfvars + rotation |
Backend without KMS / encrypt=false | State stored in plaintext on S3 | Add SSE-KMS + encrypt = true |
| State bucket without versioning | Corrupted state can't be rolled back | Enable versioning + consider periodic backups |
| IAM too open to the state bucket | All engineers can read secrets from state | Least privilege + MFA + restrict to CI runners |
| One secret store for all environments | Developers can read production secrets | Separate path/secret per environment (dev/, prod/) |
Committing tfplan | Secrets inside the plan leak | Don't commit plans; keep them only on CI runners |
In this episode 15 we learned that the state file is a plaintext secret vault — it stores passwords, credentials, and private keys as-is. We also saw how to secure it in layers: at-rest encryption with KMS, versioning, public access blocks, and strict IAM applying least privilege — because anyone who can read state can read all production secrets. Finally, we discussed secret integration from HashiCorp Vault, AWS Secrets Manager, and SOPS, as well as marking variables and outputs with sensitive = true so secret values don't appear in the terminal and logs.
Key takeaways to bring home:
sensitive = true only masks the display, it is not encryption — state still stores the original value..gitignore for *.tfvars and *.tfstate* must be created from day one.tfplan) are just as sensitive as state.Now secrets are stored neatly and state is secure. But what if someone else on the team opens an SSH port to the public, or creates a public bucket in the production environment? Manual approval alone won't be enough.
In the next episode 16 we'll discuss Policy as Code (PaC) with OPA & Sentinel — an automatic way to stop terraform apply if the code violates security rules or organizational cost standards. Stay excited!