Skip to content
network-securityintermediate#terraform#cloud-security#aws#infrastructure-as-code#devsecops

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

#SettingWhat goes wrongThe Terraform fix
1Public S3 ACLs or a disabled public access blockAnyone can list and download the bucketaws_s3_bucket_public_access_block with all four flags true; BucketOwnerEnforced ownership
2GCS bucket bound to allUsersSame, on Google CloudRemove the binding; public_access_prevention = "enforced"
3Admin or database ports open to 0.0.0.0/0SSH, RDP and database logins brute-forced or exploitedKnown CIDRs only; SSM Session Manager or IAP for admin access
4publicly_accessible = true on RDS, public IP on Cloud SQLDatabase reachable from the internetPrivate subnets; connect through a proxy inside the VPC
5Action: * on Resource: *, or AdministratorAccessOne stolen credential controls the accountNamed actions and resource ARNs
6Role trust policy with Principal: *Any AWS account can assume the roleNamed principals plus sts:ExternalId or aws:PrincipalOrgID
7aws_iam_access_key or google_service_account_keyLong-lived keys leak from CI, laptops and stateInstance roles, workload identity, OIDC federation for CI
8IMDSv1 allowedSSRF bugs read the instance role's credentialsmetadata_options { http_tokens = "required" }
9Storage not encryptedSnapshots readable when shared or copiedstorage_encrypted = true, encrypted = true, default EBS encryption
10KMS keys without rotationOne key version keeps encrypting new data indefinitelyenable_key_rotation = true, rotation_period on GCP
11Single-region CloudTrail without validationActivity in other regions goes unseen; logs can be altered or deleted without detectionis_multi_region_trail and enable_log_file_validation set to true
12Public Kubernetes control planeOne leaked kubeconfig gives cluster access from anywherePrivate 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.

Illustrated cybersecurity scene for Terraform Security Checklist: 12 Settings Behind Cloud Breaches
Illustration for Terraform Security Checklist: 12 Settings Behind Cloud Breaches.

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.

sensitive = true does not protect state

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 = true has 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_wo on aws_db_instance (paired with password_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

StageWhat to runWhat it catches
Editor and pull requestCheckov or Trivy on .tf files; a quick paste into the cloud baseline checker during reviewSettings written literally in the code
CI after planThe same scanners on terraform show -json plan.outSettings inside modules and from resolved variables
Policy gateOpen Policy Agent with Conftest, or Sentinel on HCP TerraformYour own rules, such as required tags or allowed regions
Live accountAWS Config managed rules, Security Hub, GCP Security Command CenterDrift 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

  1. Fix the twelve settings first. They are the ones breach reports keep naming. Start with public storage, open admin ports and public databases.
  2. Scan in the pull request and on the plan. Fail the build on critical findings, and require a written reason for every suppression.
  3. 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.
  4. Remove static credentials. Use OIDC federation for CI and workload identity for applications, then delete the old keys.
  5. Protect state as a secrets store, and keep secrets out of it with managed passwords and write-only arguments.
  6. Turn on the matching detections in AWS Config or Security Command Center so drift is caught between applies.
  7. Review IAM with least privilege in mind. Access Analyzer and IAM Recommender show which permissions a role actually used.

Sources & further reading