Your Terraform State File Is a Plaintext Secrets Dump, Here Is How to Fix It

Retool's new app builder is where AI-generated code ships safely
Building apps with AI is easy. Getting them to production safely is another story.
Terraform state is not a log file, it is a live inventory of every attribute of every resource Terraform manages, stored exactly as the provider returns it. When you create an RDS instance with a master password, that password is in state. When you create an IAM access key, both the key ID and secret are in state. When you generate a TLS private key with the tls provider, the private key material is in state. None of this is encrypted by Terraform itself. The only protection is the security of wherever you store the state file.
How Terraform State Exposure Happens
The most common exposure paths, in order of frequency:
Committed to git: The default local state file is terraform.tfstate in the working directory. A developer who does not have terraform.tfstate in .gitignore, or who adds it once to share with a colleague, commits it to the repository. If the repository is ever made public, or if an attacker gains read access, every secret in state is compromised. Check your git history: git log --all --full-history -- '*.tfstate' will show you if state was ever committed.
Misconfigured S3 bucket: S3 is the most common remote backend. A bucket created without a Block Public Access policy, or with an overly permissive bucket policy, can expose state to the internet or to any AWS principal in your account. AWS S3 Access Analyzer will flag this if it is configured, but many teams do not have it enabled on the state bucket.
Overpermissive IAM policies: A developer with s3:GetObject on the state bucket can read all state for all workspaces. In many organizations, the Terraform state bucket has the same IAM policies as other S3 buckets, which means application teams, data engineers, and CI/CD service accounts may have read access they should not.
Terraform Cloud / Enterprise token exposure: If you use Terraform Cloud, state is accessible to anyone with a valid organization or workspace API token. A leaked token (via a compromised developer machine, a leaked CI/CD secret, or a public repository) gives full state read access.
Step 1: Audit Your Current State for Exposure
Before fixing the backend configuration, determine whether existing state has already been exposed.
Check git history for committed state:
# Find any tfstate files ever committed
git log --all --full-history --oneline -- '**/*.tfstate' '*.tfstate'
# If found, check when and by whom
git log --all -p -- terraform.tfstate | head -100
If state was committed, treat every secret in it as compromised regardless of whether the repository is private, git history is persistent and may have been cloned.
Check S3 bucket access policy:
# Check bucket public access block
aws s3api get-public-access-block --bucket YOUR-STATE-BUCKET
# Should return all four fields as true
# If any are false, public access may be possible
# Check bucket policy for overly broad principals
aws s3api get-bucket-policy --bucket YOUR-STATE-BUCKET | jq '.Policy | fromjson'
Look for Principal: "" or Principal: {AWS: ""} in any Allow statement, this means the bucket is publicly accessible.
Check who can read state:
# Find all IAM principals with s3:GetObject on your state bucket
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::ACCOUNT:role/ROLE \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::YOUR-STATE-BUCKET/*
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Step 2: Harden the S3 Backend
The minimum required configuration for a secure Terraform S3 backend:
terraform {
backend "s3" {
bucket = "your-terraform-state-bucket"
key = "path/to/terraform.tfstate"
region = "us-east-1"
encrypt = true # Server-side encryption (SSE-S3 or SSE-KMS)
kms_key_id = "arn:aws:kms:..." # Use KMS for key management
dynamodb_table = "terraform-state-lock" # Prevent concurrent writes
# Require all access to be authenticated
# Block public access at the bucket level separately
}
}
The bucket itself must have Block Public Access enabled:
aws s3api put-public-access-block \
--bucket YOUR-STATE-BUCKET \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
Use a customer-managed KMS key (not the default aws/s3 key) for encryption, this lets you control who can decrypt state via KMS key policy, in addition to S3 bucket policy. An IAM principal needs both s3:GetObject and kms:Decrypt permissions to read state.
Step 3: Restrict Access with Separate IAM Roles
Create separate IAM roles for different state access patterns:
Plan role (read-only): Can call s3:GetObject and s3:ListBucket on state. Used by developers running terraform plan in local environments or in preview CI jobs. Cannot modify state.
Apply role (read-write): Can call s3:GetObject, s3:PutObject, s3:DeleteObject, and dynamodb actions for the lock table. Used only by production CI/CD pipelines running terraform apply. This role should require MFA for human assumption.
Audit role (read-only with logging): Can call s3:GetObject but all access is logged via S3 server access logging and CloudTrail data events. Used for security reviews and incident investigation.
Never give developers direct write access to the state bucket outside of the apply role. Developers who need to inspect state should use terraform state list and terraform state show through an authenticated CLI session, not direct S3 access.
Step 4: Rotate Secrets After Confirmed Exposure
If you confirmed state was exposed (via committed git history, public bucket, or compromised credentials), rotate in order of blast radius:
Database master passwords: For RDS, generate a new master password and update it:
aws rds modify-db-instance \
--db-instance-identifier YOUR-DB \
--master-user-password NEW_PASSWORD \
--apply-immediately
Then update the Terraform variable and run terraform apply to update state.
IAM access keys: For any aws_iam_access_key resources in state, delete the exposed keys and let Terraform create new ones:
terraform taint aws_iam_access_key.example
terraform apply
TLS private keys: For tls_private_key resources in state, the private key is fully exposed. Regenerate the key pair, re-issue any certificates signed by that key, and update all endpoints using those certificates.
Long-term fix: Move secrets out of Terraform state entirely. Use aws_secretsmanager_secret to store secrets in Secrets Manager and reference them by ARN rather than value. The secret value itself never enters Terraform state.
The Right Long-Term Approach: Keep Secrets Out of State
The most effective mitigation is architectural: do not put secrets into Terraform state in the first place.
For database passwords: Use aws_secretsmanager_secret_rotation with automatic rotation. Terraform creates the secret and configures rotation but never stores the current password value in state, Secrets Manager generates and rotates it independently.
For IAM credentials: Use IAM roles with OIDC federation instead of access keys wherever possible. No long-lived credentials means no credential exposure from state.
For TLS certificates: Use aws_acm_certificate (ACM) rather than tls_private_key (tls provider). ACM manages the private key internally, it is never returned to Terraform and never appears in state.
For arbitrary secrets: Use the vault provider with HashiCorp Vault dynamic secrets. The Vault provider can generate short-lived credentials on-demand during terraform apply without storing the credential values in state.
The bottom line
Terraform state exposure is not a theoretical risk, it is a routine finding in cloud security assessments. The two controls that matter most are: encrypting state at rest with a KMS key controlled by a restrictive key policy, and auditing who currently has s3:GetObject on your state bucket. Both take under an hour. Fixing the architectural problem, moving secrets out of state entirely, takes longer but eliminates the category entirely.
Frequently asked questions
Does encrypt = true in the S3 backend configuration actually encrypt state?
Yes, but only at rest on S3. The encrypt = true flag enables server-side encryption (SSE-S3 by default, or SSE-KMS if you specify a kms_key_id). It does not encrypt state in transit (HTTPS handles that) and it does not prevent anyone with appropriate IAM permissions from reading the decrypted content, S3 decrypts transparently for authorized requests.
What are the most critical GitHub organization security settings to enable first?
Enable two-factor authentication enforcement for all members, require signed commits on default and release branches, enable secret scanning with push protection, and restrict who can create public repositories. These four settings address the highest-frequency compromise vectors for GitHub organizations: account takeover via credential theft, supply chain tampering, unintentional credential exposure, and insider data leakage.
How do I require branch protection rules across all repositories in a GitHub organization?
Use the GitHub API or gh CLI to apply branch protection at scale: `gh api --method PUT /repos/{owner}/{repo}/branches/main/protection --input protection.json`. The protection.json should require pull request reviews (at least one), dismiss stale reviews on push, require status checks, and prohibit force pushes. For organizations with hundreds of repositories, use a script that iterates the repos list from `gh repo list {org} --json name --limit 1000` and applies the policy to each default branch.
How do I detect when a GitHub personal access token has excessive scopes?
Run `gh api /orgs/{org}/credential-authorizations` to list all active OAuth tokens and personal access tokens authorized for your organization, including their granted scopes. Tokens with `repo` scope have full read/write access to all organization repositories. Tokens with `admin:org` can modify organization settings. Review any token with these broad scopes and contact the owner to rotate it with minimal scopes. GitHub Enterprise Cloud also provides an audit log entry `oauth_access.create` for new token authorizations.
What is the difference between GitHub repository visibility controls and branch protection?
Visibility controls determine who can see and clone a repository (private, internal, or public), while branch protection rules govern how changes can be merged into specific branches regardless of repository visibility. A private repository with no branch protection still allows any member with write access to force-push to main, overwrite history, and bypass code review. Branch protection rules should be applied to all repositories regardless of visibility, because insiders and compromised accounts pose the same supply chain risk as external attackers.
Can I remove secrets from Terraform state after they have been committed?
You can move a resource from state using terraform state rm, which removes Terraform's tracking of it without destroying the resource. However, the secret value remains in older state file versions stored in S3 versioning. You need to delete all historical versions of the state file that contained the secret, or rotate the secret and treat old versions as compromised.
Is Terraform Cloud safer than self-managed S3 state?
Terraform Cloud encrypts state at rest and provides fine-grained workspace access controls. It is generally safer than a poorly configured S3 backend. However, a compromised Terraform Cloud API token still exposes all state readable by that token. The organization-level token is especially sensitive. Use workspace-scoped tokens in CI/CD rather than organization tokens.
How do I check what secrets are currently in my Terraform state?
Run terraform show -json | jq to see all resource attributes. Look for resource types that typically store secrets: aws_iam_access_key, aws_db_instance (master_password), tls_private_key (private_key_pem), aws_secretsmanager_secret_version (secret_string). These are the resources whose values are fully exposed to anyone who can read state.
Should I use sensitive = true on Terraform variables to protect secrets?
The sensitive = true attribute on variables and outputs prevents values from appearing in Terraform's console output and plan files. It does not prevent the values from being stored in state. It is useful for preventing accidental log exposure but is not a state security control.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.
Win a $2,495 Black Hat pass.
Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.
