DEV Community

Mary Mutua
Mary Mutua

Posted on

How to Handle Sensitive Data Securely in Terraform

Day 13 of my Terraform journey focused on one of the most important topics in real infrastructure work: secrets.

Every serious deployment eventually needs sensitive values:

  • database passwords
  • API keys
  • tokens
  • TLS material
  • provider credentials

The challenge is not just using those secrets. The challenge is making sure they do not leak into places they should never be.

Terraform makes infrastructure easy to define, but if you are careless with secrets, they can leak through your code, your terminal output, your Git history, and even your state file.

This post is the guide I wish I had before learning this lesson.

Why Secrets Leak in Terraform

There are three major ways secrets leak in Terraform.

If you understand these clearly, you will avoid most beginner and intermediate Terraform security mistakes.

Leak Path 1: Hardcoded in .tf Files

This is the most obvious mistake.

Wrong

resource "aws_db_instance" "example" {
  username = "admin"
  password = "super-secret-password"
}
Enter fullscreen mode Exit fullscreen mode

Why this is bad:

  • the password is now in source control
  • if you run git add, the secret is part of Git history
  • even if you delete it later, the old commit still contains it

Once a secret is committed, you must treat it as compromised.

Better

Do not write the secret directly in Terraform code.

Instead, fetch it from a secret store such as AWS Secrets Manager.

data "aws_secretsmanager_secret" "db_credentials" {
  name = "prod/db/credentials"
}

data "aws_secretsmanager_secret_version" "db_credentials" {
  secret_id = data.aws_secretsmanager_secret.db_credentials.id
}

locals {
  db_credentials = jsondecode(
    data.aws_secretsmanager_secret_version.db_credentials.secret_string
  )
}

resource "aws_db_instance" "example" {
  engine         = "mysql"
  engine_version = "8.0"
  instance_class = "db.t3.micro"
  db_name        = "appdb"

  username = local.db_credentials["username"]
  password = local.db_credentials["password"]

  allocated_storage   = 10
  skip_final_snapshot = true
}
Enter fullscreen mode Exit fullscreen mode

This keeps the secret out of your .tf files.

Leak Path 2: Secret Stored as a Variable Default

This one looks cleaner, but it is still wrong.

Wrong

variable "db_password" {
  default = "super-secret-password"
}
Enter fullscreen mode Exit fullscreen mode

Why this is bad:

  • the secret is still in source control
  • it still ends up in the file
  • it is just hidden behind a variable name

A variable default is not a secure secret store.

Better

If a variable may carry a secret:

  • do not give it a default
  • mark it sensitive
variable "db_password" {
  description = "Database administrator password"
  type        = string
  sensitive   = true
}
Enter fullscreen mode Exit fullscreen mode

Then pass the value from:

  • environment variables
  • your CI/CD secret store
  • a secure runtime mechanism

This helps reduce accidental exposure in code and CLI output.

Leak Path 3: Plaintext in Terraform State

This is the most important and most overlooked problem.

Even if you do everything else correctly:

  • no hardcoded secrets
  • no secret defaults
  • values pulled from Secrets Manager
  • outputs marked as sensitive

Terraform can still store secret values in terraform.tfstate.

That means:

  • anyone with access to the state file may be able to read secrets
  • protecting state is just as important as protecting code

This is what separates security-aware Terraform usage from surface-level secret handling.

AWS Secrets Manager Integration Pattern

For Day 13, I used AWS Secrets Manager as the secret source.

First, create the secret manually:

aws secretsmanager create-secret \
  --name "prod/db/credentials" \
  --secret-string '{"username":"dbadmin","password":"your-secure-password-here"}'
Enter fullscreen mode Exit fullscreen mode

Then read it in Terraform:

data "aws_secretsmanager_secret" "db_credentials" {
  name = "prod/db/credentials"
}

data "aws_secretsmanager_secret_version" "db_credentials" {
  secret_id = data.aws_secretsmanager_secret.db_credentials.id
}

locals {
  db_credentials = jsondecode(
    data.aws_secretsmanager_secret_version.db_credentials.secret_string
  )
}
Enter fullscreen mode Exit fullscreen mode

Then use it inside your resource:

resource "aws_db_instance" "example" {
  engine               = "mysql"
  engine_version       = "8.0"
  instance_class       = "db.t3.micro"
  allocated_storage    = 10
  db_name              = "appdb"
  username             = local.db_credentials["username"]
  password             = local.db_credentials["password"]
  skip_final_snapshot  = true
}
Enter fullscreen mode Exit fullscreen mode

Why this is better:

  • the secret is not hardcoded in Terraform
  • the secret stays centralized in Secrets Manager
  • secret updates can be managed outside the codebase

But remember:

  • this still does not eliminate the state-file risk

HashiCorp Vault Integration Pattern

For teams already using Vault, the pattern is similar.

Instead of pulling from AWS Secrets Manager, Terraform can read from Vault.

Example pattern:

data "vault_generic_secret" "db_credentials" {
  path = "secret/data/prod/db"
}

locals {
  db_username = data.vault_generic_secret.db_credentials.data["username"]
  db_password = data.vault_generic_secret.db_credentials.data["password"]
}
Enter fullscreen mode Exit fullscreen mode

Then use those values in resources just like any other data source.

Why Vault is useful:

  • strong secret lifecycle management
  • centralized policy control
  • dynamic secrets in more advanced setups
  • useful when teams already standardize on Vault across environments

The same warning still applies:

  • if Terraform consumes the value in a resource, the secret can still end up in state

Using sensitive = true

Terraform provides a sensitive = true flag for variables and outputs.

Example variable:

variable "db_password" {
  description = "Database administrator password"
  type        = string
  sensitive   = true
}
Enter fullscreen mode Exit fullscreen mode

Example output:

output "db_connection_string" {
  value     = "mysql://${aws_db_instance.example.username}@${aws_db_instance.example.endpoint}"
  sensitive = true
}
Enter fullscreen mode Exit fullscreen mode

What it does:

  • prevents Terraform from printing the raw value in plan/apply output
  • helps keep logs and terminal output cleaner

What it does not do:

  • it does not encrypt the secret
  • it does not remove it from state
  • it does not make a bad secret-handling design safe

So sensitive = true is helpful, but it is not enough on its own.

Provider Credentials: Use Environment Variables

Never put provider credentials directly in Terraform code.

Wrong

provider "aws" {
  region     = "us-east-1"
  access_key = "AKIA..."
  secret_key = "..."
}
Enter fullscreen mode Exit fullscreen mode

Better

Use environment variables:

export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_DEFAULT_REGION="us-east-1"
Enter fullscreen mode Exit fullscreen mode

This is much safer because:

  • credentials stay out of .tf files
  • CI/CD systems can inject them securely
  • they are easier to rotate than hardcoded values

In real environments, even better options are:

  • IAM roles
  • OIDC
  • short-lived credentials

State File Security Checklist

Because secrets can land in state, protecting the state file is mandatory.

Use this checklist:

  • store state remotely, not just on a laptop
  • enable S3 encryption
  • enable bucket versioning
  • block all public access
  • restrict bucket access with least-privilege IAM
  • enable DynamoDB locking
  • keep state files out of Git
  • treat anyone with state access as having potential secret access

A good backend pattern looks like this:

terraform {
  backend "s3" {
    bucket         = "your-terraform-state-bucket"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-state-locks"
    encrypt        = true
  }
}
Enter fullscreen mode Exit fullscreen mode

.gitignore Template for Terraform Projects

Every Terraform project should ignore these files:

# Terraform
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
*.tfvars
override.tf
override.tf.json
Enter fullscreen mode Exit fullscreen mode

This reduces the chance of accidentally committing:

  • local cache files
  • state files
  • variable files with secrets

Least-Privilege IAM for the State Bucket

Your state bucket should not be wide open.

At minimum, access should be limited to:

  • the IAM user/role that runs Terraform
  • only the necessary S3 actions
  • only the specific state bucket and key paths

A simple principle is:

  • allow read/write to the Terraform state bucket
  • allow lock-table access to the DynamoDB table
  • deny everyone else

The exact policy will vary by environment, but the mindset is the important part:

  • state is sensitive
  • access to state should be tightly controlled

My Main Takeaway

Day 13 changed how I think about Terraform security.

The big lesson was not just:
β€œdon’t hardcode passwords.”

The real lesson was:

  • secrets can leak in code
  • secrets can leak through defaults
  • secrets can still leak through state even when the code looks clean

That means secure Terraform work requires both:

  • better secret input patterns
  • better state protection

Using AWS Secrets Manager and sensitive = true is a strong start.

But the deeper lesson is this:
if you protect the code but ignore the state file, you have not really solved the problem.

Full Code

GitHub reference:
πŸ‘‰ Github Link

Follow My Journey

This is Day 13 of my 30-Day Terraform Challenge.

See you on Day 14 πŸš€

Top comments (0)