Terraform Security Checklist: 12 Settings Behind Cloud Breaches
The Terraform settings that recur in AWS and GCP breach reports, the HCL that fixes each one, how to keep secrets out of state, and where scanning fits.
Most cloud breaches start with a bucket someone made public, a database with a public IP, an SSH port open to the world, or a role that can do anything. In a Terraform codebase each of those mistakes is one or two lines in a pull request, and the pull request is the cheapest place to stop it.
This checklist covers the twelve settings that recur in AWS and Google Cloud breach reports, the Terraform that fixes each one, and how to keep checking once the code is deployed. Every item maps to a rule in our cloud baseline checker, which runs the same checks on pasted Terraform or plan JSON in your browser.
The twelve settings
| # | Setting | What goes wrong | The Terraform fix |
|---|---|---|---|
| 1 | Public S3 ACLs or a disabled public access block | Anyone can list and download the bucket | aws_s3_bucket_public_access_block with all four flags true; BucketOwnerEnforced ownership |
| 2 | GCS bucket bound to allUsers | Same, on Google Cloud | Remove the binding; public_access_prevention = "enforced" |
| 3 | Admin or database ports open to 0.0.0.0/0 | SSH, RDP and database logins brute-forced or exploited | Known CIDRs only; SSM Session Manager or IAP for admin access |
| 4 | publicly_accessible = true on RDS, public IP on Cloud SQL | Database reachable from the internet | Private subnets; connect through a proxy inside the VPC |
| 5 | Action: * on Resource: *, or AdministratorAccess | One stolen credential controls the account | Named actions and resource ARNs |
| 6 | Role trust policy with Principal: * | Any AWS account can assume the role | Named principals plus sts:ExternalId or aws:PrincipalOrgID |
| 7 | aws_iam_access_key or google_service_account_key | Long-lived keys leak from CI, laptops and state | Instance roles, workload identity, OIDC federation for CI |
| 8 | IMDSv1 allowed | SSRF bugs read the instance role's credentials | metadata_options { http_tokens = "required" } |
| 9 | Storage not encrypted | Snapshots readable when shared or copied | storage_encrypted = true, encrypted = true, default EBS encryption |
| 10 | KMS keys without rotation | One key version keeps encrypting new data indefinitely | enable_key_rotation = true, rotation_period on GCP |
| 11 | Single-region CloudTrail without validation | Activity in other regions goes unseen; logs can be altered or deleted without detection | is_multi_region_trail and enable_log_file_validation set to true |
| 12 | Public Kubernetes control plane | One leaked kubeconfig gives cluster access from anywhere | Private endpoint, or authorized networks limited to known ranges |
Since April 2023, AWS turns on Block Public Access and disables ACLs on every new S3 bucket by default. That default protects new buckets created with no explicit settings. It does not touch older buckets, and a Terraform resource that sets block_public_policy = false or attaches a public ACL overrides it, which is why item 1 still matters.
Worked example: the Capital One path
The 2019 Capital One breach is the clearest case of settings combining. A misconfigured web application firewall on EC2 could be tricked into making requests on the attacker's behalf, a server-side request forgery. The attacker pointed it at the instance metadata service at 169.254.169.254, which under IMDSv1 answered a plain GET with temporary credentials for the instance's IAM role. That role could list and read S3 buckets, and data on about 106 million people in the US and Canada was taken. Our Capital One breach guide covers the full timeline.
Two settings in the checklist break that chain. Item 8, IMDSv2, requires a session token obtained with a PUT request and sent in a custom header, which most SSRF primitives cannot send. IMDSv2 also rejects token requests carrying an X-Forwarded-For header, which blocks the open reverse-proxy route. A hop limit of 1 keeps the token from reaching containers behind a bridge network:
resource "aws_instance" "api" {
ami = var.ami
instance_type = "t3.small"
metadata_options {
http_tokens = "required"
http_put_response_hop_limit = 1
}
}Item 5, a scoped role, limits what any stolen credential can do. A web firewall role needs no s3:ListAllMyBuckets and no read access to customer data buckets. Either control would likely have broken the chain: IMDSv2 by refusing the credential request, the scoped role by leaving the stolen credentials with nothing worth reading.

Keep secrets out of state
Terraform state records every attribute of every resource it manages. If a resource takes a password, a private key or a token, that value lands in the state file in plain text, including values Terraform generated with random_password. Many teams keep state in a shared bucket that far more people can read than can read production secrets.
Marking a variable or output sensitive hides it in plan and apply output. The value is still written to state, and to saved plan files, if a resource attribute uses it. Treat the state backend as a secrets store whatever the configuration says.
Three ways to reduce what state holds:
- Let the service manage the secret. On RDS,
manage_master_user_password = truehas AWS generate the master password and keep it in Secrets Manager, so Terraform never sees it. - Use write-only arguments. Terraform 1.11 added write-only arguments, such as
password_woonaws_db_instance(paired withpassword_wo_version, which you bump to rotate), which are passed to the provider and never stored in plan or state. Terraform 1.10 added ephemeral values, which are also kept out of state. - Read secrets at runtime. Read them from Secrets Manager or Secret Manager in the application, rather than passing them through Terraform outputs.
Then lock down the backend itself: a private, versioned, encrypted bucket that only the roles running Terraform can read, with access logging on. Never commit terraform.tfstate or *.tfvars files holding real values. If a secret appears as a literal string in a .tf file, the secret scanner and the cloud baseline checker both flag it, and our guide to leaked API keys covers what to do next.
Where scanning fits
| Stage | What to run | What it catches |
|---|---|---|
| Editor and pull request | Checkov or Trivy on .tf files; a quick paste into the cloud baseline checker during review | Settings written literally in the code |
| CI after plan | The same scanners on terraform show -json plan.out | Settings inside modules and from resolved variables |
| Policy gate | Open Policy Agent with Conftest, or Sentinel on HCP Terraform | Your own rules, such as required tags or allowed regions |
| Live account | AWS Config managed rules, Security Hub, GCP Security Command Center | Drift from console changes and resources Terraform does not manage |
tfsec, once a common choice, has been folded into Trivy. Plan JSON contains sensitive values unredacted, so treat plan files and CI artifacts like state. Scanning the plan matters because a module call such as module "db" { source = "./rds" } hides its settings from a scanner that only reads the root files. The plan JSON contains every resource with its final values.
The live-account stage closes the loop. Each checklist item has a matching detection, for example the AWS Config rules restricted-ssh for item 3, rds-instance-public-access-check for item 4 and ec2-imdsv2-check for item 8. If someone changes a setting in the console, Terraform shows drift on the next plan, and the Config rule alerts the same day.
How to defend
- Fix the twelve settings first. They are the ones breach reports keep naming. Start with public storage, open admin ports and public databases.
- Scan in the pull request and on the plan. Fail the build on critical findings, and require a written reason for every suppression.
- Set safe defaults at the account level. S3 account-level public access block, default EBS encryption, and organization policies or SCPs that deny the worst settings outright.
- Remove static credentials. Use OIDC federation for CI and workload identity for applications, then delete the old keys.
- Protect state as a secrets store, and keep secrets out of it with managed passwords and write-only arguments.
- Turn on the matching detections in AWS Config or Security Command Center so drift is caught between applies.
- Review IAM with least privilege in mind. Access Analyzer and IAM Recommender show which permissions a role actually used.
Related guides
Sources & further reading
- Amazon S3 will automatically enable S3 Block Public Access and disable ACLs for all new buckets starting April 2023 (Amazon Web Services)
- Manage sensitive data in your configuration (HashiCorp)
- Configure the instance metadata options for existing instances (Amazon Web Services)