Post

Is Your Terraform State Hiding Sensitive Data? The Power of `terraform_data` in v1.16

Are you exposing sensitive information in your Terraform state without knowing it? This post takes a deep dive into Terraform v1.

Is Your Terraform State Hiding Sensitive Data? The Power of `terraform_data` in v1.16

If you pass a database password through standard resource attributes, it sits in plaintext inside your .tfstate file forever. Encrypting the remote backend protects against external breaches, but anyone with read access to the state file can still extract the credentials. You need a way to pass ephemeral values during a plan without permanently burning them into state.

TL;DR: Terraform 1.16 transforms the terraform_data block into a core mechanism for handling transient execution data safely. Managing these values directly within the plan lifecycle prevents them from lingering in your state file as static plaintext secrets. This guide provides exact migration commands, configuration diffs, and verification steps to audit and secure your deployments today.

What you’ll walk away with:

  • A clear understanding of how Terraform state handles plaintext data.
  • The exact syntax to migrate from external providers to native execution blocks.
  • A validation checklist using native CLI commands to audit your state files for leaks.

Why Does Terraform State Expose Sensitive Configurations?

Terraform captures the exact configuration of every resource and stores it as a JSON object in the state file, including sensitive strings like passwords or API keys. Unless a field is explicitly marked as sensitive by the provider schema, Terraform writes it in plaintext. Anyone running terraform pull can extract these values directly.

The terraform_data block is a built-in resource that allows you to store temporary data and trigger replacement lifecycles without relying on the external null provider.

Because it is built directly into the CLI, it interacts cleanly with Terraform’s core evaluation engine. You avoid dragging in third-party providers just to handle local data transformations or trigger external shell scripts.

Use state encryption at rest in your remote backend, but never assume it replaces the need for careful attribute handling in your HCL.

How Do You Use terraform_data for Ephemeral Executions?

You pass temporary data to the resource via its input attribute, and use the triggers_replace attribute to force execution only when specific upstream values change. This structure keeps execution-only data scoped strictly to the lifecycle of the run. The state file tracks the hash of the data rather than explicitly duplicating long-lived secrets across multiple compute instances.

Let’s model how Aicademy uses this to trigger database schema migrations without hardcoding the temporary migration token into a persistent compute instance configuration.

flowchart TD
    A["Upstream Secret"] --> B{"Is it permanent?"}
    B -->|"Yes"| C["Store in Vault"]
    B -->|"No"| D["terraform_data input"]
    D --> E["Trigger Provisioner"]
    E --> F["Execute API Call"]
    C --> G["Standard Resource"]

By routing ephemeral tokens through this workflow, Aicademy ensures the migration token is evaluated during the plan but is not unnecessarily persisted in the final compute resource state.

Always bind ephemeral provisioners to terraform_data rather than the compute instance itself, isolating the lifecycle of the execution from the lifecycle of the infrastructure.

How Do You Migrate From null_resource to terraform_data?

Replacing legacy external providers is straightforward because the new block uses identical core concepts but replaces the triggers map with the triggers_replace argument. You no longer need a provider "null" block in your configuration. This simple swap eliminates an external dependency and maps directly to the native implementation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- resource "null_resource" "db_migration" {
-   triggers = {
-     version = var.schema_version
-   }
-   provisioner "local-exec" {
-     command = "./migrate.sh"
-   }
- }

+ resource "terraform_data" "db_migration" {
+   triggers_replace = [
+     var.schema_version
+   ]
+   provisioner "local-exec" {
+     command = "./migrate.sh"
+   }
+ }

Migration Checklist

Before you merge your provider updates, verify your repository against this migration checklist:

  • Identify all instances of null_resource in your .tf files.
  • Replace the resource type with terraform_data.
  • Rename the triggers attribute to triggers_replace.
  • Run terraform plan to ensure the core logic remains unchanged.
  • Remove hashicorp/null from your required_providers block.

This refactor immediately drops the dependency on the HashiCorp null provider. It also guarantees that your execution plan strictly follows the behavior defined in Terraform v1.16. You run fewer providers, resulting in faster initialization phases and a reduced supply chain surface area.

Update your module registries to drop the null provider dependency entirely when standardizing on Terraform >= 1.16.

How Can You Verify Your State File is Secure?

You audit state files locally by generating a JSON representation of the plan and inspecting it for plaintext secrets using command-line tools. Running terraform plan -out=tfplan followed by terraform show -json tfplan provides the raw data structure Terraform intends to commit. This reveals precisely what your backend will store.

First, generate the plan file using the official plan command syntax.

1
terraform plan -out=tfplan
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # terraform_data.db_migration will be created
  + resource "terraform_data" "db_migration" {
      + id               = (known after apply)
      + output           = (known after apply)
      + triggers_replace = [
          + "v2.1.0",
        ]
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Next, output the state data as JSON and pipe it to jq to look for accidental exposures in the planned values.

1
terraform show -json tfplan | jq '.planned_values'
Click to view the verbose JSON plan output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
  "root_module": {
    "resources": [
      {
        "address": "terraform_data.db_migration",
        "mode": "managed",
        "type": "terraform_data",
        "name": "db_migration",
        "provider_name": "terraform.io/builtin/terraform",
        "schema_version": 0,
        "values": {
          "input": null,
          "triggers_replace": [
            "v2.1.0"
          ]
        },
        "sensitive_values": {
          "triggers_replace": [
            false
          ]
        }
      }
    ]
  }
}

Reviewing the JSON confirms exactly what gets passed to the provider before you run an apply. If your governance team requires automated compliance checks against these JSON plans, you must ensure your policy engine parses the sensitive_values arrays correctly. If you skip this, you will encounter the exact scenario detailed in Why Your tfpolicy deny Rule Is Silently Failing in Production (And How to Debug It).

Run terraform show -json tfplan | grep "your-secret" on your local machine to catch plaintext leaks before they hit your remote state bucket.

What Are the Best Alternatives for Data Handling in Terraform?

Choosing the right data handling method depends on the persistence requirements of your configuration and whether the data originates inside or outside Terraform. You should use native blocks for execution triggers, external data sources for dynamic API lookups, and explicit sensitive variables for persistent credentials. Mismatching these tools leads to either state bloat or security vulnerabilities.

Implementation Method Handles Lifecycles? Requires External Provider? Best For
terraform_data Yes No Winner: Triggering provisioners natively
null_resource Yes Yes Legacy environments (< 1.4)
local_file No Yes Writing outputs to local disk
sensitive variables No No Redacting outputs in the CLI

For a deeper understanding of how these choices impact your automated security checks, review Manual Policy Reviews vs. Automated tfpolicy Guardrails: Accelerate Compliance by 90%. Aligning your built-in resource usage with your compliance tooling reduces false positives significantly. It also simplifies the rules documented in The tfpolicy Block No One Reads That Controls Your Entire Cloud Governance.

Default to terraform_data for all local execution triggers unless you are locked into an extremely old Terraform version.

Bottom Line

Migrating to the native block is a mandatory cleanup step for any team still relying on external providers to handle execution lifecycles. It tightens your provider supply chain and ensures transient data is handled correctly within the execution plan. Stop treating your state file like a temporary key-value store. If you need to practice securing state files and migrating legacy resources in a controlled sandbox, check out Aicademy Labs for hands-on, project-based exercises.

Next up in the series: We will configure remote backend encryption to protect your state files at rest.

FAQ

What is the exact command to output a Terraform plan as JSON?

Run terraform show -json tfplan where tfplan is the binary file generated by terraform plan -out=tfplan. This allows you to inspect the exact data structures and sensitive values Terraform intends to write.

Why does my Terraform state file show sensitive variables in plaintext?

Terraform must track the exact state of remote infrastructure to calculate drift, meaning variables not explicitly masked by the provider schema are stored in plaintext. You must encrypt your remote backend bucket or use built-in lifecycle management to prevent unauthorized access.

How do you trigger a replacement in terraform_data?

Use the triggers_replace argument, which accepts an array or map of values. Whenever a value inside this argument changes between runs, Terraform destroys and recreates the resource block automatically.

Can I completely remove the null provider from my Terraform 1.16 configuration?

Yes. By replacing all instances of null_resource with the built-in block, you can safely remove hashicorp/null from your required_providers block entirely.

Does terraform_data support local-exec provisioners?

Yes, it fully supports standard provisioner blocks like local-exec and remote-exec. This makes it the ideal native replacement for triggering scripts during an apply phase.

Further Reading


🚀 Ready to get hands-on? Spin up an interactive AI or Kubernetes Sandbox at Aicademy Labs for free.

This post is licensed under CC BY 4.0 by the author.