Terraform Security Misconfigurations Explained

Terraform Security Misconfigurations Explained

Written by

in

Infrastructure as code lets you define a whole cloud environment in text files and build it with one command. That speed is why Terraform security matters so much: one wrong line in an HCL file can open a database to the whole internet or hand a role far more power than it needs. This post walks through the mistakes people make most often in Terraform, shows an insecure snippet next to its fixed version, and explains how to catch these problems before you run apply.

Why Terraform security is really a code review problem

Once your infrastructure is code, it can be read like code. The same static reasoning teams already use on application source applies here. You read the declared resources, follow the values into their fields, and flag the ones that describe a dangerous state. A security group that names 0.0.0.0/0 is dangerous whether or not anyone has run apply yet. You do not need the live account to see it, because the intent is written down in the file.

That is the useful mental shift. A Terraform plan is a set of claims about what the world should look like. If a claim says a sensitive port is open to everyone, the plan is wrong before it ever touches the cloud. So the best place to catch these bugs is in the text, early, on every change.

A security group open to the world

The classic mistake is a firewall rule that allows every address on a sensitive port. Here a Postgres database is exposed to the entire internet:

resource "aws_security_group" "db" {
  name = "db-sg"
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

The cidr_blocks = ["0.0.0.0/0"] line means any host anywhere can reach port 5432. The fix is to allow only the network that actually needs the database, such as your private subnet:

resource "aws_security_group" "db" {
  name = "db-sg"
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["10.0.1.0/24"]
  }
}

Same resource, one changed value, completely different exposure. A reviewer scanning for the string 0.0.0.0/0 on a database or admin port catches this in seconds.

A storage resource made public

The next common bug is a bucket that anyone can read. People set this by accident when copying an example, and the data inside is often reports, backups, or user uploads. This ACL makes the whole bucket readable by the world:

resource "aws_s3_bucket_acl" "reports" {
  bucket = aws_s3_bucket.reports.id
  acl    = "public-read"
}

The fix is to keep the bucket private and add an explicit block so a later change cannot make it public by mistake:

resource "aws_s3_bucket_acl" "reports" {
  bucket = aws_s3_bucket.reports.id
  acl    = "private"
}

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
}

Public storage is such a frequent source of leaks that it deserves its own read. We cover the pattern in depth in S3 bucket misconfiguration.

Secrets hardcoded in code and leaked to the state file

Terraform tempts you to put a password right into a variable so the plan just works:

variable "db_password" {
  default = "S3cr3tP4ss"
}

Two things go wrong here. First, the secret now lives in version control, so anyone with repository access has it. Second, and this one surprises people, Terraform records applied values in its state file. Even if you pass the password in at run time instead of hardcoding it, the plaintext value is written into terraform.tfstate. If that state file sits in a public repo or an open bucket, the secret is exposed.

The fix is to keep the secret out of code entirely and pull it from a manager at apply time:

variable "db_password" {
  type      = string
  sensitive = true
}

data "aws_secretsmanager_secret_version" "db" {
  secret_id = "prod/db/password"
}

Mark the variable sensitive so it stays out of plan output, and never commit a value for it. The state file still needs care, which is the next point.

Once your infrastructure is a text file, every secret in that file is a secret in your git history, and every open port in that file is an open port in production.

An over permissive role written in code

Roles defined in Terraform have the same trap as any access policy: it is faster to grant everything than to work out what is actually needed. This role can do anything to anything:

resource "aws_iam_role_policy" "app" {
  name = "app-policy"
  role = aws_iam_role.app.id
  policy = jsonencode({
    Statement = [{
      Effect   = "Allow"
      Action   = "*"
      Resource = "*"
    }]
  })
}

An attacker who gets a foothold in this app inherits full account access. The fix is least privilege: name the exact actions and the exact resources the app uses.

resource "aws_iam_role_policy" "app" {
  name = "app-policy"
  role = aws_iam_role.app.id
  policy = jsonencode({
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject", "s3:PutObject"]
      Resource = "arn:aws:s3:::reports/*"
    }]
  })
}

A wildcard action paired with a wildcard resource is a red flag any reviewer or scanner can spot in the text. Wide roles are how a small bug turns into a full takeover, which we walk through in IAM privilege escalation.

Drift between the code and the real environment

The last problem is not a bad line of code. It is when the code and the live account stop matching. Someone opens the console during an incident, widens a security group by hand, and forgets to undo it. The Terraform file still says the port is closed, but the real world says it is open. That gap is called drift.

Terraform can show it to you. Run a plan with no changes and read what it wants to fix:

terraform plan
# Note: Objects have changed outside of Terraform
#   ~ ingress cidr_blocks = ["10.0.1.0/24"] -> ["0.0.0.0/0"]

That output is telling you the live rule no longer matches the code. Drift matters because your safe looking file is no longer the truth. If you review only the code and never compare it to reality, you can pass a review while the account is wide open. Run terraform plan on a schedule and treat any surprise diff as an alert, not noise.

Defenses that actually help

None of these bugs need a live account to find. They are all visible in the text, which means you can build simple habits to stop them:

  • Scan before apply. Read the declared resources on every change and block the merge if a plan opens a sensitive port, makes storage public, or grants a wildcard. This is static analysis on infrastructure, and it belongs in your pipeline the same way it does for app code.
  • Keep state encrypted and out of version control. Store terraform.tfstate in an encrypted backend with access limits, never in the git repo, because it holds plaintext secrets.
  • No plaintext secrets in code. Pull passwords and keys from a secrets manager at run time and mark the variables sensitive.
  • Least privilege for every role. Name exact actions and exact resources. Treat a "*" action or resource as a bug to justify, not a default.
  • Watch for drift. Run terraform plan regularly and investigate any change the code did not ask for.

These same ideas show up across cloud config, not just Terraform. For the wider pattern, our deep dives collect related teardowns, including how access rules break in Kubernetes RBAC misconfiguration.

Reading declared config and flagging the dangerous state before it ships is exactly the kind of assumption testing an autonomous researcher is built to do, whether the assumption lives in application code or in an HCL file. You can read how we approach that on our about page.

Frequently asked questions

What are the most common Terraform security mistakes?

The frequent ones are a security group that allows 0.0.0.0/0 on a sensitive port, a storage resource left public, secrets hardcoded in a variable or leaked into the state file, an over permissive role that grants a wildcard action, and drift between the code and the live account. All of them are visible by reading the HCL before you apply it.

Are secrets safe in a Terraform state file?

No. Terraform writes applied values, including passwords and keys, into the state file in plaintext, even if you passed them in at run time instead of hardcoding them. If that state file lands in version control or an open bucket, the secrets are exposed. Keep state in an encrypted backend with tight access and pull secrets from a manager at apply time.

How do I catch a Terraform misconfiguration before apply?

Because the infrastructure is now code, you can read the declared resources and flag the dangerous ones without a live account. Run a static scan on every change that blocks a merge when a plan opens a sensitive port, makes storage public, or grants a wildcard role. This is the same static reasoning teams already use on application source.

What is Terraform drift and why does it matter?

Drift is when the live environment stops matching the code, usually because someone changed a resource by hand in the console. Your file may say a port is closed while the real account has it open. Run terraform plan on a schedule and treat any diff the code did not ask for as an alert, since a clean looking file can hide a wide open account.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.