Secrets in variables & outputs

sensitive = true is not encryption; where secrets really belong.

Intermediate22 min · lesson 4 of 12

A card receipt prints `**** **** **** 4471`. The masking happens on the way to the paper, inside the printer. The payment system behind the till still holds every digit, and anyone with access to that database reads the full number without effort. Terraform's `sensitive = true` behaves the same way. It changes what gets printed. It changes nothing about what gets stored.

Secrets walk into Terraform (the tool that builds cloud infrastructure from configuration files) through *input variables*, and walk back out through *outputs*, the values a configuration publishes for humans and other tools to read. Between those two doors, every value you hand Terraform gets written to three places people forget about: the state file, which is Terraform's own record of everything it has built, the saved plan file, and whatever your CI (continuous integration, the service that runs your build automatically on every change) system kept in its logs. The sensitive mark covers the first inch of that path and none of the rest. Here is where each leak lives, with commands you can run on your own machine to prove it.

What `sensitive = true` actually does

Declare a variable or output sensitive and Terraform attaches a *mark* to the value. The mark behaves like food colouring stirred into a bowl: everything downstream comes out tinted. Join the value to another string, drop it into a template, pass it into a module, and the result stays marked. Anywhere a marked value would be printed in human-readable form, Terraform swaps in a placeholder: `(sensitive value)` inside a plan diff, `<sensitive>` in the outputs listing. That mark is display metadata, held in memory for the length of a single run. It does not encrypt, hash, or omit anything on disk. Start with a configuration that gets it wrong.

main.tf
variable "db_password" {
type = string
sensitive = true
}
# Deliberately missing sensitive = true
output "db_password" {
value = var.db_password
}
terminal
$ terraform apply -auto-approve
output
│ Error: Output refers to sensitive values
│ on main.tf line 7:
│ 7: output "db_password" {
│ To reduce the risk of accidentally exporting sensitive data that was
│ intended to be only internal, Terraform requires that any root module
│ output containing sensitive data be explicitly marked as sensitive, to
│ confirm your intent.
│ If you do intend to export this data, annotate the output value as
│ sensitive by adding the following argument:
│ sensitive = true

That refusal is the one place the mark genuinely stops you doing something dangerous: a marked value cannot escape through an unmarked root output. Add `sensitive = true` to the output block and the apply goes through. Now ask Terraform for the same value three different ways. The third goes through `jq`, a small command-line tool for pulling fields out of JSON (JavaScript Object Notation, the text format most tools trade data in).

terminal
$ terraform apply -auto-approve
$ terraform output
$ terraform output -raw db_password
$ terraform show -json | jq -r '.values.outputs.db_password.value'
output
Changes to Outputs:
+ db_password = (sensitive value)
You can apply this plan to save these new output values to the Terraform
state, without changing any real infrastructure.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
db_password = <sensitive>
# $ terraform output
db_password = <sensitive>
# $ terraform output -raw db_password
correct-horse-battery-staple
# $ terraform show -json | jq -r '.values.outputs.db_password.value'
correct-horse-battery-staple

Two of those commands printed the password. The Terraform documentation is blunt about why: when you use the `-json` or `-raw` flags, sensitive values are displayed in plain text, because scripts need the real thing. Harmless on your laptop, ugly in a pipeline where a helper script's output lands in a build log a hundred people can read. The state file behind those commands keeps the value permanently, so anyone who can run `terraform state pull` against that workspace owns the credential outright. That group is bigger than people expect: every engineer who can run a plan, and every CI job with read access to the storage bucket holding the state. No exploit required, one command.

What the sensitive mark covers, and what it never touches
Redacted by sensitive = true (the print layer)
plan and apply text
the attribute renders as (sensitive value) in the diff a reviewer reads
terraform output with no arguments
the listing prints db_password = <sensitive>
marks travel through expressions
string joins, templates, and module boundaries keep the value masked downstream
unmarked root outputs
Terraform fails the run rather than publish a marked value in the clear
Full plaintext, mark or no mark (the storage layer)
terraform.tfstate and terraform state pull
the value verbatim, one jq query away for anyone with read access to the backend
terraform show -json
documented to print sensitive values in plain text; "sensitive": true rides along beside the value as a hint for tools
the saved plan file (-out=tfplan)
its variables block carries every value you passed in, marked or not
terraform output -raw NAME
prints the bare secret by design so scripts can read it (strings, numbers and booleans only)
TF_LOG=trace provider logs
internal values and the request bodies the provider sends to the cloud
Everything on the right is a normal read by someone who already has permission, not an attack. That is why access control on state, plan files, and build logs decides whether the secret is safe.

Keep terraform.tfvars out of git

A key safe bolted beside the front door is a fine idea until somebody writes the combination on the lid. A `.tfvars` file is that lid. It holds a plain list of `name = value` pairs that Terraform loads on its own when the file is called `terraform.tfvars` or ends in `.auto.tfvars`, and it sits in the working directory right next to the code, so `git add .` sweeps it up without comment. A repository twenty people can clone becomes twenty copies of your production credentials, plus every fork and every CI cache.

.gitignore
# Local Terraform working directory
.terraform/
# State: plaintext secrets, never commit
*.tfstate
*.tfstate.*
# Variable files that carry secrets
*.tfvars
*.tfvars.json
!example.tfvars
# Saved plans carry variable values verbatim
tfplan
*.tfplan
terminal
$ git check-ignore -v terraform.tfvars
$ git log --all --full-history --oneline -- '*.tfvars'
$ git show be48674:terraform.tfvars
output
.gitignore:9:*.tfvars terraform.tfvars
# $ git log --all --full-history --oneline -- '*.tfvars'
7e0fee7 remove tfvars, use env vars
be48674 add prod tfvars
# $ git show be48674:terraform.tfvars
db_password = "correct-horse-battery-staple"

The first command confirms the rule is live: `git check-ignore -v` names the file and the line number of the pattern that matched, and stays silent when no rule matches the path at all. The other two commands ruin afternoons. Deleting a `.tfvars` file in a later commit takes it out of the working tree and leaves it sitting in history, readable by anybody who has ever cloned the repository. If that search returns commits, the credential is burned. Rotate it first, rewrite history with `git filter-repo` second, in that order.

Hand the secret in from the CI secret store

Rather than storing the value near the code at all, hand it to the process at the moment it runs, the way a courier hands an envelope over at the door instead of posting it through the letterbox. Terraform reads any environment variable named `TF_VAR_<name>` as the input variable `<name>`, matching the name letter for letter, capitals included. Your CI platform keeps the value in its own encrypted store, injects it for the length of one job, and nothing touches disk.

.github/workflows/tf.yml
name: terraform
on: [pull_request]
permissions:
contents: read
id-token: write # OIDC (OpenID Connect): a short-lived login to
# the cloud, so no access keys are stored here
jobs:
plan:
runs-on: ubuntu-latest
env:
TF_IN_AUTOMATION: "true"
TF_INPUT: "0"
# Injected for this job only. The name after TF_VAR_ is case-sensitive.
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false # the wrapper copies stdout into step outputs,
# which is how plan text reaches PR comments
- run: terraform init -input=false
- run: terraform plan -no-color -out=tfplan
# Note what is absent: no upload-artifact step for tfplan. See below.

GitHub Actions replaces registered secret values with `***` wherever they show up in log output, and that safety net is thinner than it looks, because the masking works on exact string matches. Base64-encode the value, wrap it in JSON, or let a tool print it with escaped quotes inside, and the mask sails straight past it. Treat log masking as a backstop behind the sensitive mark, never as the reason you are covered.

-var on the command line is not the same as TF_VAR_
`terraform apply -var 'db_password=hunter2'` writes the secret into your shell history and into the process table, where `ps aux` shows it to every user logged into that machine. Environment variables stay out of `ps` output on Linux, because the kernel file that holds them, `/proc/<pid>/environ`, is readable only by the process owner and root, which is exactly why `TF_VAR_` is the safer door. Separately, never run a job with `TF_LOG=debug` or `TF_LOG=trace` while a real secret is in play: those logs capture internal values and the provider's API (application programming interface) traffic with the cloud, and CI keeps build logs long after you have forgotten the run existed.

Reading from a secret manager, and the caveat

The grown-up answer is to keep the secret in a system built for holding secrets (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) and have Terraform fetch it when it runs. The fetch happens through a *data source*, which works like phoning the front desk to ask which room a guest is in: Terraform asks the provider about something that already exists, reads the answer, and changes nothing.

main.tf
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/db/master"
}
resource "aws_db_instance" "app" {
identifier = "app-prod"
engine = "postgres"
instance_class = "db.t4g.micro"
allocated_storage = 20
username = "app"
password = jsondecode(
data.aws_secretsmanager_secret_version.db.secret_string
)["password"]
}
terminal
$ terraform state pull | jq -r '.resources[]
| select(.type == "aws_secretsmanager_secret_version")
| .instances[0].attributes.secret_string'
output
{"username":"app","password":"pM4!tz9QeR2vXd7L"}

Terraform records every data source it reads into state, the same way it records resources it created. Fetching from Vault or Secrets Manager moved the secret out of your repository and into your state file. The blast radius changed shape, it did not drop to zero. The password now sits in the state backend in plain text, with a second copy in the `aws_db_instance` attributes, because the provider stores back whatever it was handed. Most teams stop here, believing the job is done.

Terraform 1.10 added *ephemeral* resources and 1.11 added *write-only* arguments, whose names end in `_wo`, for exactly this gap. An ephemeral block fetches a value for the length of a single run and never writes it down. A write-only argument accepts a value at apply time without recording it in state or in the plan. On AWS that pairing is `ephemeral "aws_secretsmanager_secret_version"` feeding `password_wo`, alongside a companion `password_wo_version` number you increment whenever the password changes, since Terraform cannot see the value well enough to notice on its own. Provider and resource coverage is still filling in, so check the documentation for the specific resource before you plan a migration around it.

The plan you post on a pull request

Plan comments are the best review habit in infrastructure work and the leakiest. A bot runs `terraform plan`, pastes the diff into a pull request comment, and the reviewer sees precisely what is about to change. On a public repository, so does everyone else, permanently, search engines included. Below is a reproduction you can run on a laptop in a minute with no cloud account, using the `local` provider, which writes files on your own disk.

main.tf
variable "api_token" {
type = string
sensitive = true
}
resource "local_file" "note" {
filename = "${path.module}/note.txt"
content = "token is ${var.api_token}"
}
terminal
$ terraform plan -no-color -out=tfplan
$ terraform show -json tfplan | jq '.variables'
$ terraform show -json tfplan | jq -r '.planned_values.root_module.resources[]
| .address + " -> " + .values.content'
output
# local_file.note will be created
+ resource "local_file" "note" {
+ content = (sensitive value)
+ content_base64sha256 = (known after apply)
+ content_base64sha512 = (known after apply)
+ content_md5 = (known after apply)
+ content_sha1 = (known after apply)
+ content_sha256 = (known after apply)
+ content_sha512 = (known after apply)
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "./note.txt"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
# $ terraform show -json tfplan | jq '.variables'
{
"api_token": {
"value": "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"
}
}
# $ ... jq -r '.planned_values.root_module.resources[] ...'
local_file.note -> token is ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8

The human-readable plan behaved perfectly. A reviewer sees `(sensitive value)` and nothing more. The JSON version of that identical plan hands the token over twice, once in the `variables` block and once inside the planned resource values. So anything that eats plan JSON is handling your secrets in the clear: a policy engine such as conftest or OPA (Open Policy Agent), a cost estimator, a scanner like Checkov or Trivy running in plan mode, a bot that renders a prettier diff. Those tools are worth running. Keep their input and their logs inside the same trust boundary as state.

A saved plan file is as sensitive as state
`terraform plan -out=tfplan` writes the value of every input variable into the plan file, sensitive marks included, and `terraform show -json` reads them back in plain text. Terraform 1.10 did add an `ephemeral = true` argument for variables, which keeps a value out of both the plan and the state, though an ephemeral value can only be used in a few places (provider configuration, provisioners, other ephemeral blocks, and write-only arguments), so it will not cover an ordinary resource argument. Until it does, never upload `tfplan` or its JSON as a CI artifact a wider audience can download, never pipe raw plan JSON into a pull request comment, and add `tfplan` plus `*.tfplan` to `.gitignore`. If a plan carrying a live credential was already posted, deleting the comment fixes nothing: comment edit history and scrapers both outlive you. Assume it is public and rotate the credential.

Treat state as the secret

The rule that survives all of the above: assume anything Terraform touches is sitting in state in plain text, and put your controls on state rather than on printed output. In practice that means encryption at rest, an IAM (identity and access management, the rules deciding who may read what) policy scoped to the CI role and a small break-glass group, state split per environment so one leaked file exposes one blast radius, and access logging so an unexpected read at 3 a.m. is visible the next morning. Keep using the sensitive mark, because it does stop shoulder-surfing and casual log grazing. It is a courtesy to whoever is reading the terminal, and no kind of security boundary.

Stronger still: arrange for the secret never to pass through Terraform at all. Let Terraform build the safe and let the cloud provider put the money in and keep the only key. On RDS (Amazon's Relational Database Service) a single argument does this. Set `manage_master_user_password` and AWS generates the master password itself, stores it in Secrets Manager, and rotates it on a schedule, while your configuration never sees the value. You cannot set that argument and a `password` at the same time, which is rather the point.

main.tf
resource "aws_db_instance" "app" {
identifier = "app-prod"
engine = "postgres"
instance_class = "db.t4g.micro"
allocated_storage = 20
username = "app"
# No password argument anywhere. AWS generates it, keeps it in
# Secrets Manager, and rotates it. State receives an ARN, not a secret.
manage_master_user_password = true
}
output "db_secret_arn" {
value = aws_db_instance.app.master_user_secret[0].secret_arn
}

State now holds an ARN (Amazon Resource Name), which is a pointer worth nothing without permission to read the secret behind it, and the application fetches the password from Secrets Manager at start-up under its own identity. The same shape works far beyond RDS: create an empty `aws_secretsmanager_secret` in Terraform and let a separate process write the value into it, or give the application a Vault role and cut Terraform out of the exchange entirely. The next lesson turns to the code itself, putting Trivy (which absorbed the tfsec Terraform checks) and Checkov in front of your HCL (HashiCorp Configuration Language) so a hardcoded credential or a wide-open security group fails the build before a reviewer looks at it.

Quick check
01A teammate argues that every variable and output carrying a credential is marked `sensitive = true`, so the database password is protected and the state bucket only needs the same access controls as any other bucket. What is wrong with that reasoning?
Correct — `sensitive = true` is display metadata carried during a run. `terraform output -raw` and `terraform show -json` print the value on purpose, and the state file holds it permanently in plain text.
Incorrect — Encryption at rest protects the storage medium, and decryption is transparent to any identity allowed to read the object. The person running `terraform state pull` gets plaintext either way.
Incorrect — Wrong on the first half. Nothing about the mark encrypts anything, in state or elsewhere. The plan file is a genuine second exposure, but it is an additional problem, not a replacement for the state one.
Incorrect — Sensitive variables have redacted output this way since Terraform 0.14, and no release since has turned redaction into encryption. Terraform 1.10 added ephemeral resources and ephemeral variables, and 1.11 added write-only arguments; those are a different mechanism that keeps values out of state and plan files in the first place.
02The lesson recommends handing a secret to Terraform through the environment variable `TF_VAR_db_password` rather than with `terraform apply -var 'db_password=...'`. Why is the environment-variable route safer on a shared machine?
Incorrect — how you supply a value does not change whether it reaches state; both leave it there the same way. The difference is exposure around the run.
Incorrect — Terraform encrypts neither; `TF_VAR_` is simply harder for other users on the box to read.
Incorrect — the sensitive mark comes from the variable's declaration, not from how the value is passed in.
Correct — `-var` leaks the secret into shell history and the `ps aux` process table for every user on the machine, whereas the environment variable is readable only by the owner and root.
03To stop hardcoding a database password, a team moves it into AWS Secrets Manager and reads it in Terraform with a `data "aws_secretsmanager_secret_version"` block, wiring the value into `aws_db_instance.password`. They conclude the secret is no longer exposed. What is still true after this change?
Incorrect — Terraform records every data source it reads into state, so even a read-only fetch writes the value down.
Correct — the value is now in state twice, so reading from Secrets Manager relocated the exposure from the repository to the state file instead of removing it.
Incorrect — that describes `manage_master_user_password`, where AWS keeps the value and state holds only an ARN; a plain data-source read puts the actual secret into state.
Incorrect — nothing encrypts the value inside state; it sits there in plaintext regardless of where it came from.

Related