Secrets in variables & outputs
sensitive = true is not encryption; where secrets really belong.
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.
variable "db_password" {type = stringsensitive = true}# Deliberately missing sensitive = trueoutput "db_password" {value = var.db_password}
$ terraform apply -auto-approve
╷│ 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).
$ terraform apply -auto-approve$ terraform output$ terraform output -raw db_password$ terraform show -json | jq -r '.values.outputs.db_password.value'
Changes to Outputs:+ db_password = (sensitive value)You can apply this plan to save these new output values to the Terraformstate, without changing any real infrastructure.Apply complete! Resources: 0 added, 0 changed, 0 destroyed.Outputs:db_password = <sensitive># $ terraform outputdb_password = <sensitive># $ terraform output -raw db_passwordcorrect-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.
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.
# 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 verbatimtfplan*.tfplan
$ git check-ignore -v terraform.tfvars$ git log --all --full-history --oneline -- '*.tfvars'$ git show be48674:terraform.tfvars
.gitignore:9:*.tfvars terraform.tfvars# $ git log --all --full-history --oneline -- '*.tfvars'7e0fee7 remove tfvars, use env varsbe48674 add prod tfvars# $ git show be48674:terraform.tfvarsdb_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.
name: terraformon: [pull_request]permissions:contents: readid-token: write # OIDC (OpenID Connect): a short-lived login to# the cloud, so no access keys are stored herejobs:plan:runs-on: ubuntu-latestenv: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@v3with: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.
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.
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 = 20username = "app"password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"]}
$ terraform state pull | jq -r '.resources[]| select(.type == "aws_secretsmanager_secret_version")| .instances[0].attributes.secret_string'
{"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.
variable "api_token" {type = stringsensitive = true}resource "local_file" "note" {filename = "${path.module}/note.txt"content = "token is ${var.api_token}"}
$ 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'
# 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.
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.
resource "aws_db_instance" "app" {identifier = "app-prod"engine = "postgres"instance_class = "db.t4g.micro"allocated_storage = 20username = "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.