Services Process Packages Blog Resources Free Assessment Let's talk

Infrastructure as Code Is Your Biggest Security Win

Ran into this the other day. A client had a security group wide open on port 22 to 0.0.0.0/0 in their production AWS account. Nobody knew who created it. Nobody knew when. Nobody could explain why.

Someone had clicked a button in the console six months ago, probably to debug something, and never cleaned it up. No PR, no review, no record. Just a hole in the firewall that had been sitting there silently for half a year.

This is the kind of thing that stops happening when your infrastructure is code.

The real argument for IaC isn't efficiency

Most teams adopt Terraform or Pulumi or CDK because it's faster than clicking around in consoles. That's true. But it's NOT the main benefit.

The main benefit is that every infrastructure change becomes a code change. And code changes go through pull requests. And pull requests get reviewed. And reviews catch things.

That security group? If it had been a Terraform change, someone would've seen this in a diff:

resource "aws_security_group_rule" "ssh_debug" {
  type              = "ingress"
  from_port         = 22
  to_port           = 22
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.app.id
}

And someone on the team would've said "hey, why is this open to the entire internet?" before it ever touched production.

That's the win. Not speed. Reviewability.

Policy-as-code catches what humans miss

Code review is good. Automated policy enforcement is better.

Humans get tired. Humans approve PRs on Friday afternoons. Humans miss the cidr_blocks = ["0.0.0.0/0"] buried in a 400-line Terraform plan. Tools don't.

Here's a basic tfsec check that catches exactly this:

tfsec . --minimum-severity HIGH

Output:

Result: CRITICAL - Security group rule allows ingress from 0.0.0.0/0
Resource: aws_security_group_rule.ssh_debug
Location: main.tf:42-49

Impact:     Your port is exposed to the internet
Resolution: Set a more restrictive CIDR range

That runs in CI before terraform apply ever executes. The misconfiguration never reaches AWS. This is the same "guardrails, not gates" approach described in Security Is a Feature, Not a Phase.

For more granular control, Open Policy Agent (OPA) lets you write custom policies that match your org's specific requirements:

deny[msg] {
  resource := input.resource.aws_security_group_rule[name]
  resource.cidr_blocks[_] == "0.0.0.0/0"
  msg := sprintf("Security group rule '%s' allows unrestricted ingress", [name])
}

This is where IaC goes from "nice to have" to actual security infrastructure. You're encoding your security requirements as testable, enforceable rules that run on every single change. No exceptions. No "I'll fix it later."

Terraform modules encode your security baseline

Here's something I've done at every company I've worked at and every client engagement since — build opinionated Terraform modules that bake in security defaults.

Instead of letting every team define their own S3 bucket config (and inevitably forgetting to block public access), you give them a module:

module "app_bucket" {
  source = "git::https://github.com/your-org/terraform-modules.git//s3-bucket?ref=v2.1.0"

  bucket_name = "app-data-${var.environment}"
  environment = var.environment
}

Inside that module, the defaults are already set:

resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  versioning_configuration {
    status = "Enabled"
  }
}

Public access blocked. Encryption enabled with KMS. Versioning on. Every bucket, every time, without anyone having to remember.

The team consuming the module doesn't need to be a security expert. They just use the module and get secure defaults for free. Guardrails, not gates.

I've seen this pattern eliminate entire categories of findings from security audits. The first audit finds 15 misconfigured S3 buckets. You build the module. The next audit finds zero. That's it. One module.

Drift detection closes the back door

IaC only works as a security control if it's ACTUALLY the source of truth. The moment someone logs into the console and makes a manual change, you've got drift — your running infrastructure no longer matches your code.

This is where drift detection matters. Tools like terraform plan (run on a schedule), Spacelift, env0, or even a simple cron job can catch when reality diverges from code:

#!/bin/bash
terraform plan -detailed-exitcode -out=drift.plan 2>&1

EXIT_CODE=$?

if [ $EXIT_CODE -eq 2 ]; then
  echo "DRIFT DETECTED — infrastructure has diverged from code"
  # send alert to Slack, PagerDuty, wherever
fi

Exit code 2 from terraform plan means there are changes — something in AWS doesn't match what's defined in your Terraform. That's your signal that someone bypassed the process.

Run this nightly. Pipe the alerts somewhere your team actually looks. When drift shows up, you have two options: update the code to match reality (if the change was intentional) or revert the change (if it wasn't). Either way, code stays the source of truth.

The pattern that actually works

I've set this up at multiple companies now. The stack looks like this:

  1. Terraform modules with secure defaults for every resource type your team uses
  2. tfsec or Checkov running in CI on every pull request
  3. OPA policies for org-specific rules that go beyond generic checks
  4. Drift detection running on a schedule, alerting to a channel the team monitors
  5. No console access for production — or at minimum, read-only console with all writes going through code

That last one is where teams push back the hardest. "But what about emergencies?" Fair. Have a break-glass procedure. Log it. Alert on it. Review it after the fact. But the default path should always be through code.

When I audit infrastructure that follows this pattern, the findings list is short. When I audit infrastructure where "some things are in Terraform and some things were done in the console," the findings list is long. Every time.

Start somewhere

If you're not doing any of this yet, don't try to boil the ocean. Pick one thing.

If I had to choose, I'd start with the Terraform modules. Build one module for your most commonly created resource — S3 buckets, security groups, IAM roles, whatever your team touches most. Bake in the secure defaults. Make it the blessed path.

Then add tfsec to your CI pipeline. One tfsec . command in your GitHub Actions workflow. Takes five minutes to set up. You can layer on dependency review for supply chain protection at the same time.

That's two changes. One afternoon of work. And you've just made every future infrastructure change reviewable, scannable, and built on secure defaults.

What does your IaC coverage look like right now? Are you at 100% of production resources defined in code, or is there still stuff that only exists as console clicks? If you want a structured assessment, our free SaaS Security Checklist covers IaC along with 60+ other security items.

Secure your stack

Get our free 65-item SaaS Security Checklist delivered to your inbox.

No spam. Just the checklist + practical security tips.

Check your inbox!

EI

Evan Ippolito

DevSecOps consultant, 6+ years at Nike, ZeroFox, IDX. Helping SaaS teams ship secure.

Want help securing your infrastructure?

I help B2B SaaS teams eliminate static credentials, harden containers, and lock down CI/CD pipelines — delivered as code you keep.

Let's talk
Originally published on zerocreds.io