Skip to content
Infrastructure as Code iac security 4 min read

Managing Secrets

Secrets — database passwords, API keys, TLS private keys, OAuth client secrets — are the most dangerous values to mishandle in Infrastructure as Code. Terraform configuration is usually committed to version control and shared across a team, so anything hardcoded into a .tf file is effectively public to everyone with repository access (and anyone who later clones the history). This page covers how to keep secrets out of your source, inject them at runtime from a dedicated secret store, and deal with the uncomfortable truth that secrets almost always end up in Terraform state.

Never hardcode secrets

The first rule is the simplest: a literal secret should never appear in a .tf file, a terraform.tfvars committed to git, or a default value in a variable block. The following is the anti-pattern to avoid.

# DO NOT DO THIS — secret is now in git history forever
resource "aws_db_instance" "main" {
  identifier     = "app-prod"
  engine         = "postgres"
  username       = "appuser"
  password       = "S3cr3t-Passw0rd!"  # leaked the moment you commit
  instance_class = "db.t3.medium"
  allocated_storage = 20
}

Even if you later delete the line, the value persists in git history and must be treated as compromised and rotated. Instead, declare the secret as a sensitive variable with no default and supply it from outside the codebase.

variable "db_password" {
  description = "Master password for the application database"
  type        = string
  sensitive   = true
}

resource "aws_db_instance" "main" {
  identifier        = "app-prod"
  engine            = "postgres"
  username          = "appuser"
  password          = var.db_password
  instance_class    = "db.t3.medium"
  allocated_storage = 20
}

Marking a variable sensitive = true tells Terraform to redact it from CLI output and plan logs. It does not remove it from state — more on that below.

Inject secrets at runtime

With no default value, Terraform must obtain db_password from somewhere at plan/apply time. The cleanest sources are environment variables and CI/CD secret stores, never a committed file.

# Environment variable: TF_VAR_<name> maps to var.<name>
export TF_VAR_db_password="$(op read 'op://infra/prod-db/password')"
terraform apply

In CI, the value comes from the pipeline’s encrypted secret store (GitHub Actions secrets, GitLab CI variables, etc.) and is exposed only as TF_VAR_db_password for the duration of the job. This keeps the secret out of the repo and out of any file on disk.

The data-source pattern

A more robust approach is to never hand Terraform the raw secret at all — instead, store it in a managed secret service and have Terraform read it through a data source. Terraform fetches the value at runtime, uses it, and you never type it into a variable.

AWS Secrets Manager

data "aws_secretsmanager_secret" "db" {
  name = "prod/app/db"
}

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

resource "aws_db_instance" "main" {
  identifier        = "app-prod"
  engine            = "postgres"
  username          = "appuser"
  password          = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]
  instance_class    = "db.t3.medium"
  allocated_storage = 20
}

The secret_string here holds JSON like {"username":"appuser","password":"..."}, so jsondecode(...) extracts a single field. This works identically under OpenTofu, since the AWS provider is the same.

SSM Parameter Store

For lower-cost or simpler use cases, AWS Systems Manager Parameter Store works the same way. Use a SecureString parameter and let the provider decrypt it.

data "aws_ssm_parameter" "db_password" {
  name            = "/prod/app/db_password"
  with_decryption = true
}

resource "aws_db_instance" "main" {
  identifier        = "app-prod"
  engine            = "postgres"
  username          = "appuser"
  password          = data.aws_ssm_parameter.db_password.value
  instance_class    = "db.t3.medium"
  allocated_storage = 20
}

Comparing secret sources

SourceWhere the secret livesAudit & rotationBest for
TF_VAR_* env varCI secret store / shellManual, in CIBootstrapping, simple pipelines
AWS Secrets ManagerManaged AWS serviceAutomatic rotation, CloudTrailProduction app credentials
SSM Parameter StoreManaged AWS serviceCloudTrail, manual rotationConfig + lightweight secrets
HashiCorp VaultVault clusterDynamic, short-lived secretsMulti-cloud, dynamic credentials

Secrets still land in state

This is the critical gotcha. Any secret a resource consumes — whether injected via a variable or read through a data source — is written in plaintext into the Terraform state file. The aws_db_instance.password, the decoded Secrets Manager value, and the SSM parameter value all appear unencrypted in terraform.tfstate.

Output:

$ terraform show -json | jq '.values.root_module.resources[]
    | select(.address=="aws_db_instance.main") | .values.password'
"S3cr3t-Passw0rd!"

Warning: A sensitive variable is redacted from CLI output but is NOT encrypted in state. Treat the state file itself as a secret. Anyone who can read your state can read every credential it touched.

Because of this, you must use a backend that encrypts state at rest and restricts access — for example an S3 backend with SSE-KMS and a locking mechanism. The data-source pattern reduces the blast radius (secrets are not in git) but does not keep them out of state; protecting state is a separate, mandatory control covered in the sensitive-state page.

Best Practices

  • Never commit a literal secret; declare it as a sensitive variable with no default value.
  • Inject secrets via TF_VAR_* environment variables from a CI secret store, never from a committed tfvars file.
  • Prefer the data-source pattern (Secrets Manager, SSM, Vault) so Terraform reads secrets from a managed, auditable store at runtime.
  • Always encrypt remote state at rest and lock down read access — secrets land in state regardless of how they were supplied.
  • Rotate any secret that has ever been committed to git or printed to a log; consider it permanently compromised.
  • Add *.tfvars and *.tfstate* to .gitignore, and scan commits for leaked secrets in CI before they merge.
  • Use dynamic, short-lived credentials from Vault where supported to shrink the window of exposure.
Last updated June 14, 2026
Was this helpful?