I've Seen Terraform State Files in S3 Buckets With No Encryption, No Versioning, and Public Read Access
Let me tell you about a penetration test I heard about from a colleague at a red team shop. The engagement scope was a mid-sized SaaS company's AWS environment. Standard stuff. They got initial access through a misconfigured IAM role — nothing novel. But what they found next elevated the engagement from "routine findings" to a full incident debrief: a world-readable S3 bucket named something like company-prod-terraform-state. Inside it, a single file. terraform.tfstate. 43 megabytes. It contained every RDS master password, every ElastiCache auth token, the API keys for their Stripe and Twilio integrations, IAM access key pairs for service accounts, and — my personal favorite — the ARN and credentials for their secrets manager bootstrap role that was used to seed Secrets Manager in the first place.
They didn't need to run a single exploit after that. The state file was the exploit.
The problem isn't that Terraform is insecure. Terraform is a tool, and tools don't make architectural decisions. The problem is that most teams treat the state file as a build artifact rather than what it actually is: a complete, plaintext inventory of your infrastructure's most sensitive configuration. If an attacker gets your state file, they don't need your runbooks, they don't need to enumerate your environment, and they definitely don't need to brute force anything. You handed them the map.
What's Actually in That File (This Will Make You Uncomfortable)
Open any non-trivial terraform.tfstate that manages an RDS instance and look at the resources array. You'll find the password attribute in plaintext inside the instances[0].attributes block. Terraform has to store it — how else would it know whether the password changed on the next plan? The same goes for any resource that accepts a secret as a configuration input: aws_elasticache_replication_group auth tokens, aws_msk_cluster client authentication, aws_iam_access_key — that one stores the secret value in cleartext in the state because Terraform needs it for drift detection.
Run terraform state show aws_iam_access_key.deploy right now on a real environment and see what comes back. The secret access key is right there. Then ask yourself who has s3:GetObject on your state bucket.
This is why the Terraform documentation itself says, under the S3 backend documentation: "Terraform state can contain sensitive data... Access to the S3 bucket is sufficient to access the state." They've been warning about this for years. Teams still ignore it because the state file doesn't look dangerous at first glance — it's JSON, it's verbose, it's boring-looking. Until you pipe it through jq and start pulling values.
Beyond raw credentials, the state file is a reconnaissance goldmine in a way that pure credential exposure isn't. It gives an attacker your full topology: every subnet ID, every security group rule, every IAM policy attachment, every Lambda function and its environment variables. You know what's useful for lateral movement? Knowing exactly which security groups allow cross-VPC traffic before you've even touched the network. The state file answers that question in seconds.
The Git Commit That Ruined Someone's Weekend
Before remote backends were common, it was genuinely normal to commit terraform.tfstate to version control. I'm not talking about 2014. I've seen it in repos created in 2021. The .gitignore in HashiCorp's own Terraform template explicitly lists *.tfstate and *.tfstate.*, and yet.
The insidious part about state files in Git isn't the current commit — it's the history. You rotate your RDS password because you followed the right procedure. You update your Terraform configuration, run apply, and the new password lands in the state file. You commit the updated state. Old password is gone from HEAD. Except git log --all -p -- terraform.tfstate will surface every previous version of the state, including the one with the old password, and the one before that, going back however far your repo history extends.
Tools like truffleHog and git-secrets will find these. Any attacker doing basic OSINT on a leaked or publicly accessible repo will find these. And if the passwords were reused — which they often are in environments that haven't matured beyond "we rotate because compliance said so" — then rotating in Terraform doesn't actually accomplish much.
The fix here isn't just "use a remote backend." It's: audit your Git history right now with git log --all --full-history -- '**/terraform.tfstate'. If that returns results, you have a problem that persists regardless of what you do going forward. Rotate everything that was ever in those files. Then use git filter-repo to rewrite history if you need to, though that has its own operational implications on shared repositories.
The Right S3 Backend Setup (And Why Most People Get It Half-Right)
The blessed pattern for remote state in AWS is an S3 backend with DynamoDB state locking. Most teams who've graduated past local state know this. But "using S3 and DynamoDB" is not the same as "configured correctly," and the gap between those two things is where incidents live.
Here's what the bucket configuration actually needs: server-side encryption with a customer-managed KMS key (not aws:kms with the default key — a CMK you control, with a key policy that explicitly restricts who can decrypt). Versioning enabled, because if someone overwrites or corrupts your state, you need a recovery path. A bucket policy that denies all access except from your CI/CD role and your Terraform execution principals — and that policy should include an explicit Deny on s3:DeleteBucket, s3:DeleteObject, and s3:PutBucketPolicy for everyone including account root unless you're coming from a specific condition. Block all public access, obviously. VPC endpoint for S3 if your CI runs inside the VPC, so state access never traverses the public internet.
The KMS key policy question is where teams fall down. They create a CMK, attach it to the bucket, check the "encryption enabled" box, and move on. But then the key policy has "Principal": "*" with conditions, or the key is accessible to the entire account, and now encryption is theater. The key policy should explicitly enumerate which roles can call kms:Decrypt on that key. Your developer IAM users probably shouldn't be on that list, especially in production. Your state should be readable by your CI system and your break-glass runbook, not by every engineer who can assume a broad role.
On the DynamoDB side, the locking table also needs encryption at rest and should be in a VPC endpoint-accessible configuration. Locking prevents concurrent apply operations from corrupting state, but an attacker who can delete items from your lock table can cause a denial-of-service on your deployment pipeline or, worse, create a race condition during an apply that corrupts state.
The question I ask every team when I review their IaC setup: Who has s3:GetObject on your state bucket? Pull the bucket policy. Pull the IAM policies for every role that could satisfy s3:GetObject via resource-based or identity-based policy. Do that audit. You'll be surprised what you find attached to developer roles or CI systems with broader permissions than intended.
terraform plan Leaks Secrets Too, and Nobody Talks About That
Here's a scenario that's more common than the "state file in public S3" horror story because it hides better: your CI pipeline runs terraform plan and streams the output to your logging system. Datadog, Splunk, CloudWatch Logs, whatever. The plan output includes the diff between current state and desired configuration. If a secret value changes — even if it's marked sensitive = true in your provider or module — older provider versions or custom resources will print the old and new values directly in the plan output.
The sensitive attribute in Terraform marks values to be redacted in CLI output, but this only applies to outputs and variables explicitly marked as such. Provider resources don't always mark their secret attributes sensitive. Check your provider version and whether nonsensitive() is being called anywhere in your modules. And even when values are properly marked sensitive, the plan output still shows whether a secret changed, which in some cases leaks timing and rotation information.
More practically: who has read access to your CI logs? If you're streaming Terraform plan output to a centralized logging platform and your logging access controls are coarser than your state bucket access controls, you've created a second exfiltration path. The state bucket might be locked down. The CloudWatch log group might be accessible to every engineer in the org.
Managed Platforms and Policy as Code
Terraform Cloud and its HCP successor handle state storage, provide role-based access controls on state, and offer audit logs for state access. That's meaningfully better than a homegrown S3 setup with misconfigured KMS. The state is encrypted at rest, access is controlled through workspace permissions, and you get a history of every state read and write with the associated user identity.
But Terraform Cloud is not a security silver bullet. The workspace permissions model requires deliberate configuration — by default, new workspaces can be accessible to all members of an organization depending on your tier and settings. Variable sets, which are how you inject secrets into Terraform Cloud runs, need to be scoped correctly. A variable set that contains AWS credentials and is attached at the organization level instead of specific workspaces is a broad blast radius if any workspace is compromised or misused.
Spacelift, Env0, and Atlantis are the alternatives worth knowing. Atlantis is self-hosted and pairs well with existing GitOps workflows — plan and apply output gets posted back to pull request comments, which brings its own secret-exposure risk in public or broadly accessible repos. Spacelift and Env0 are commercial and offer more mature access controls, policy-as-code integration, and audit logging.
Scanning your Terraform configurations with tfsec, Checkov, or KICS before apply is table stakes. These tools catch common misconfigurations — S3 buckets without encryption, security groups with 0.0.0.0/0 ingress on port 22, RDS instances without deletion protection — and integrating them into a pre-commit hook or CI stage costs almost nothing. HashiCorp's Sentinel and OPA integration for Terraform go further: they let you write policies that gate whether a terraform apply can proceed at all based on the plan output.
The terraform_remote_state data source deserves its own mention. It's designed to let one Terraform configuration read outputs from another configuration's state — useful for cross-stack references. It's also a mechanism for cross-account secret exfiltration if the state file being referenced contains sensitive outputs and the data source is accessible to a role in a less-controlled account. Scope your state bucket access policies to specific account IDs and roles, not just the current account.



