Security & Secrets Management

The three places secrets leak in a Terraform workflow and the three different fixes, why sensitive = true is not a secrets strategy, and locking down the state backend as the production-admin boundary it actually is.

advanced 20 min lesson hands-on task included

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

LeakFix
Configuration files — hard-coded credentials committed to version controlSecret manager data source, or environment variables
CLI output and logs — values printed at plan/apply, captured by CI log storagesensitive = true
State — every sensitive attribute, plain text, regardless of markersEncrypt 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. Commit terraform.tfvars.example with placeholder values instead.
  • Private keys. The file without .pub, ever, with permissions set to 0400 locally.
  • .terraform.lock.hcl is 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.