Here's a fact that's easy to forget until it bites you: a Terraform state file
stores resource attributes in plaintext — and that includes secrets. RDS
master passwords, IAM access keys baked into user data, API tokens passed as
variables. They're all just sitting there in terraform.tfstate as readable
JSON.
That's fine-ish when the file lives in an encrypted S3 backend that three people
can touch. It is not fine the moment your tooling starts reading, persisting,
or rendering those attributes. I learned this building a tool that ingests
tfstate, and I want to walk through where the leak hides and the small guard that
closes it.
Where the secrets actually live
In a state file, managed resources look like this:
{
"resources": [
{
"mode": "managed",
"type": "aws_db_instance",
"instances": [
{
"attributes": {
"identifier": "prod-db",
"username": "admin",
"password": "S3cr3t-do-not-leak",
"endpoint": "prod-db.xxxx.rds.amazonaws.com:5432"
}
}
]
}
]
}
The password is right there. If your tool parses this state, stores those
attributes in a database, and then renders them in a diff view or an audit log,
you've just copied that secret into N new places — none of which the original
S3 encryption protects.
Step 1: know what you're holding
Before you can protect secrets you need to detect them. A simple, boring
key-name match goes a surprisingly long way. Iterate the managed resources, look
at the first instance's attributes, and flag any key whose name contains a
sensitive substring:
SECRET_KEY_PATTERNS = (
"password", "secret", "token", "private_key",
"access_key", "credential", "auth",
)
PLACEHOLDER = "***"
def detect_secrets(tfstate: dict) -> dict:
"""Return {field_name: count}. Empty dict means nothing sensitive found."""
found: dict[str, int] = {}
for resource in tfstate.get("resources", []):
if resource.get("mode") != "managed" or not resource.get("instances"):
continue
attrs = resource["instances"][0].get("attributes") or {}
for k, v in attrs.items():
if not v or v == PLACEHOLDER: # skip empty / already masked
continue
if any(p in k.lower() for p in SECRET_KEY_PATTERNS):
found[k] = found.get(k, 0) + 1
return found
Two details that matter in practice:
-
Lowercase the key before matching.
Password,PASSWORD, anddb_passwordshould all hit. - Skip empty and already-masked values. Otherwise you "detect" a secret in every empty string and re-flag values you already scrubbed, and your warning count becomes noise.
I use this to show the user a heads-up at import time: "this state file
contains 3 sensitive fields; they will be masked before storage." Transparency
beats a silent transform.
Step 2: scrub before it touches the database
Detection is the warning; scrubbing is the fix. Same key-name rule, but now you
replace the value:
def scrub_secrets(attrs: dict) -> dict:
return {
k: PLACEHOLDER if any(p in k.lower() for p in SECRET_KEY_PATTERNS) else v
for k, v in attrs.items()
}
The important word is before. Scrub at the boundary — the instant the data
enters your system — not right before you render it. If the raw value ever lands
in your database, a stray log line, an admin panel, or a SELECT * can leak it
later, no matter how careful your template is. Mask at ingestion and the secret
simply never exists in your storage.
Why key-name matching (and not something smarter)
You could reach for entropy detection or regex value-matching. I deliberately
didn't, for two reasons:
- False negatives are dangerous; false positives are cheap. Masking a field that wasn't actually secret costs you nothing — you didn't need its value in a diff anyway. Missing a real secret is the failure that matters. A broad key-name match errs in the safe direction.
- It's predictable. Anyone can read the pattern list and know exactly what gets masked. Entropy heuristics surprise you at the worst time.
The tradeoff: a secret stored under an innocent key name (say, config_blob)
slips through. So treat the pattern list as a living thing and extend it when you
find a new shape. But as a default, "mask anything that looks sensitive by
name, at the door" is a strong baseline.
Takeaways
- tfstate is plaintext. The risk isn't the file — it's every tool that copies attributes out of it.
- Detect sensitive fields by lowercased key-name substring match, skipping empty/already-masked values, and tell the user what you found.
- Scrub at ingestion, not at render time, so the raw value never reaches storage or logs.
- Prefer over-masking to under-masking; secrecy failures are one-directional.
I bake this into a self-hosted tool that ingests tfstate to build an AWS asset
ledger and detect drift — secrets get masked before anything is stored. It's open
source (MIT), one docker compose up: syncvey.com.
How do you keep state-file secrets from leaking into your own tooling — masking,
a separate secrets backend, or never reading those attributes at all?























