Most cloud incidents that start with "misconfigured" did not start in the cloud console. They started in a pull request that changed a .tf file, passed review because the diff looked like plumbing, and was applied by a pipeline that trusted it. Terraform makes infrastructure reviewable, which is the good news. The bad news is that the same five mistakes come back again and again, and each one is a single line.
This post walks through those five, shows the vulnerable and fixed HCL for each, and ends with where a scan belongs in the pipeline.
1. Storage that anyone can read
The classic Terraform leak is an S3 bucket made public by an ACL or a bucket policy with "Principal": "*". AWS now blocks public access on new buckets by default, but Terraform can switch that off as easily as it can switch it on, and older buckets keep whatever they had.
# Vulnerable: public ACL and the account guard rails turned off
resource "aws_s3_bucket_acl" "reports" {
bucket = aws_s3_bucket.reports.id
acl = "public-read"
}
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = false
restrict_public_buckets = false
}# Fixed: all four Block Public Access settings on, no public ACL
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}AWS recommends turning on all four settings at the account level as well as per bucket (Amazon S3 Block Public Access). If a bucket genuinely serves public files, put a CDN in front of it and keep the bucket private.
2. Security groups open to the world
0.0.0.0/0 on port 22, 3389 or a database port is just as familiar. It usually arrives as a "temporary" debugging rule that nobody removed.
# Vulnerable: SSH and Postgres reachable from any IPv4 address
resource "aws_security_group_rule" "db_ingress" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.db.id
}# Fixed: only the application tier can reach the database
resource "aws_security_group_rule" "db_ingress" {
type = "ingress"
from_port = 5432
to_port = 5432
protocol = "tcp"
source_security_group_id = aws_security_group.app.id
security_group_id = aws_security_group.db.id
}Remember the IPv6 twin: ipv6_cidr_blocks = ["::/0"] is the same mistake. For administrative access, a bastion, a VPN or a session manager beats any CIDR list.
3. Unencrypted resources
Encryption at rest is usually one argument, which is exactly why it gets forgotten. RDS instances, EBS volumes and many managed caches still need it set explicitly in Terraform.
# Vulnerable
resource "aws_db_instance" "main" {
engine = "postgres"
instance_class = "db.t3.medium"
publicly_accessible = true
storage_encrypted = false
}# Fixed
resource "aws_db_instance" "main" {
engine = "postgres"
instance_class = "db.t3.medium"
publicly_accessible = false
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
}Notice publicly_accessible in the same block. A database with a public endpoint and an open security group is one leaked password away from a breach, and that combination is worth a reviewer's attention even when each line looks harmless on its own.
4. Wildcard IAM
"Action": "*" with "Resource": "*" gives a role full administrative power. The softer version, s3:* on every bucket, is more common and almost as bad, because it covers deleting data and changing bucket policies.
# Vulnerable: the CI deploy role can do anything to anything
data "aws_iam_policy_document" "deploy" {
statement {
effect = "Allow"
actions = ["*"]
resources = ["*"]
}
}# Fixed: the actions the deploy needs, on the bucket it deploys to
data "aws_iam_policy_document" "deploy" {
statement {
effect = "Allow"
actions = ["s3:PutObject", "s3:GetObject", "s3:ListBucket"]
resources = [
aws_s3_bucket.site.arn,
"${aws_s3_bucket.site.arn}/*",
]
}
}Wildcards also hide in trust policies. A role whose assume_role_policy trusts a broad principal, or an OIDC provider without a sub condition, lets the wrong caller in even when the permission policy is tight.
5. Secrets in variables and in state
There are two separate problems here. The first is a credential typed into HCL or a committed terraform.tfvars. The second is less obvious: Terraform writes resource attributes into its state file in plain text, and HashiCorp's own documentation says that values marked sensitive = true are still stored in both state and plan files (Manage sensitive data). The flag hides output on the terminal. It does not encrypt anything.
# Vulnerable: literal password in code, and it will land in state anyway
resource "aws_db_instance" "main" {
username = "admin"
password = "Summer2026!"
}# Better: generate and manage the secret outside the config
resource "aws_db_instance" "main" {
username = "admin"
manage_master_user_password = true
}The rules that follow:
- Never commit
*.tfstate,*.tfstate.backupor real*.tfvarsfiles. Add them to.gitignoreon day one. - Use a remote backend with encryption at rest and tight access controls. For the S3 backend that means
encrypt = trueand a bucket only the pipeline can read. - Where your Terraform and provider versions support them, ephemeral values and write-only arguments keep a value out of state entirely, per the same HashiCorp page.
- Treat anyone who can read state as someone who can read every secret in it.
Scanning Terraform in CI
Review catches some of this. A scan catches it every time, at the cost of seconds, and it works on the source before terraform plan ever runs. The order that works for most teams:
- Pre-commit or pre-push: a fast scan of the changed files, so the obvious cases never leave the laptop.
- Pull request: a full scan of the IaC directory, gated on new findings only, so existing debt does not block every merge.
- Before apply: policy checks against the plan for anything that depends on computed values.
Vulkro reads Terraform (*.tf), AWS CloudFormation, Serverless Framework, Azure Bicep and ARM templates as part of a normal vulkro scan, alongside the application code in the same repository. The released list is on the supported files page. For Terraform it flags security groups open to 0.0.0.0/0 or ::/0, publicly accessible databases, unencrypted storage and credentials written into .tf files. A committed state file is checked by its own detector, TFSTATE-SECRET-001, which reports credential attributes held in clear text. Everything runs on your machine; the IaC never leaves it.
# Every finding, including IaC
vulkro scan ci .
# Application code only, when you want the IaC findings out of the way
vulkro scan ci . --scope srcIn a pipeline, the ci preset exits 1 on Critical or High findings, which is the gate. The GitHub Actions guide has a ready workflow that also uploads SARIF.
FAQ
What are the most common Terraform security misconfigurations?
Public storage buckets, security groups open to 0.0.0.0/0, unencrypted databases and volumes, IAM policies with * actions or resources, and secrets written into variables or committed state files. Most are a single argument in a single resource.
Does sensitive = true encrypt values in Terraform state?
No. It hides the value from CLI output, but Terraform still writes it to state and plan files in plain text. Protect state with an encrypted remote backend and strict access, and keep it out of git.
Should IaC scanning run before or after terraform plan?
Both have a place. Scanning the source runs in seconds and catches literal mistakes in the pull request. Plan-based checks catch values that are only known after Terraform resolves variables and modules.
Start a 14-day trial at /start/ and scan the Terraform in your next pull request. Related: Hardcoded secrets in private repos and Diff-scoped security gates.