Introduction – The invisible gap

When a new AWS account is created, a default VPC and its accompanying network ACL (NACL) appear automatically. The default NACL is permissive: it allows all inbound and outbound traffic on every port. For many teams this “works out of the box,” but the openness masks a serious blind spot. Attackers who gain a foothold in a compromised instance can freely pivot across the entire VPC, bypassing security groups and IAM policies.

This article does not explain how to configure a NACL; instead it explains why you should never rely on the default one and walks you through a reproducible Terraform‑based replacement that enforces a least‑privilege rule set.

Understanding the default NACL

The default NACL has the following rule set (ordered by rule number):

# Default NACL rules (AWS console view)
# Inbound
100 allow 0.0.0.0/0 0-65535 TCP
*   allow 0.0.0.0/0 0-65535 UDP
*   allow 0.0.0.0/0 0-65535 ICMP

# Outbound
100 allow 0.0.0.0/0 0-65535 TCP
*   allow 0.0.0.0/0 0-65535 UDP
*   allow 0.0.0.0/0 0-65535 ICMP

Because the rule numbers are low and the “*” wildcard rule matches everything, any traffic that reaches the subnet is permitted. The NACL is stateless, meaning return traffic must also be explicitly allowed. The default configuration satisfies the “allow all” requirement, which is convenient for quick demos but disastrous for production workloads.

Why the permissive default is a hidden liability

1. Lateral movement. If an attacker compromises a single EC2 instance, they can scan the entire VPC CIDR range, reach databases, caches, and internal APIs without encountering a firewall.

2. Unintended exposure. Services that are meant to be private (e.g., internal Elasticsearch clusters) become reachable from any other subnet in the same VPC, violating the principle of defense‑in‑depth.

3. Audit fatigue. Security tools flag “allow all” NACLs as high‑severity findings, but because the default is auto‑created, teams often ignore the alerts, creating a habit of overlooking real misconfigurations.

4. Compliance gaps. Regulations such as PCI‑DSS and HIPAA require explicit deny rules for unused ports. The default NACL fails these checks out of the box.

Hardening the VPC with Terraform – Step‑by‑step

The following Terraform configuration demonstrates how to replace the default NACL with a custom, deny‑by‑default policy that only permits traffic you explicitly declare.

# main.tf
terraform {
  required_version = "≥ 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "≈ 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

# Create a new VPC (or import an existing one)
resource "aws_vpc" "custom" {
  cidr_block = var.vpc_cidr
  tags = {
    Name = "custom-vpc"
  }
}

Next, we define a dedicated NACL with a deny‑all rule as the catch‑all entry, followed by allow rules for the services that truly need exposure.

# acl.tf
resource "aws_network_acl" "custom_acl" {
  vpc_id = aws_vpc.custom.id
  tags = {
    Name = "custom‑acl"
  }
}

# Deny all inbound traffic by default
resource "aws_network_acl_rule" "deny_all_inbound" {
  network_acl_id = aws_network_acl.custom_acl.id
  rule_number    = 100
  egress         = false
  protocol       = "-1"   # all protocols
  rule_action    = "deny"
  cidr_block     = "0.0.0.0/0"
}

# Deny all outbound traffic by default
resource "aws_network_acl_rule" "deny_all_outbound" {
  network_acl_id = aws_network_acl.custom_acl.id
  rule_number    = 100
  egress         = true
  protocol       = "-1"
  rule_action    = "deny"
  cidr_block     = "0.0.0.0/0"
}

# Example: allow inbound SSH from corporate IP range
resource "aws_network_acl_rule" "allow_ssh_in" {
  network_acl_id = aws_network_acl.custom_acl.id
  rule_number    = 200
  egress         = false
  protocol       = "6"   # TCP
  rule_action    = "allow"
  cidr_block     = var.corp_ip
  from_port      = 22
  to_port        = 22
}

# Example: allow outbound HTTPS to the internet
resource "aws_network_acl_rule" "allow_https_out" {
  network_acl_id = aws_network_acl.custom_acl.id
  rule_number    = 200
  egress         = true
  protocol       = "6"
  rule_action    = "allow"
  cidr_block     = "0.0.0.0/0"
  from_port      = 443
  to_port        = 443
}

After the NACL resources are created, associate it with each subnet that hosts workloads. Terraform can automate the association, ensuring no subnet is left attached to the default ACL.

# subnet_association.tf
resource "aws_subnet" "app_subnet" {
  vpc_id            = aws_vpc.custom.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, 1)
  availability_zone = "${var.aws_region}a"
  tags = {
    Name = "app‑subnet"
  }
}

resource "aws_network_acl_association" "app_assoc" {
  subnet_id      = aws_subnet.app_subnet.id
  network_acl_id = aws_network_acl.custom_acl.id
}

Run the standard Terraform workflow:

# Terminal commands
terraform init
terraform plan -var="aws_region=us-east-1" -var="vpc_cidr=10.0.0.0/16" -var="corp_ip=203.0.113.0/24"
terraform apply -auto-approve

The plan output will clearly show that the default NACL is untouched, while a new, restrictive ACL is attached to every subnet you define.

Testing the hardened configuration

After deployment, verify that the default NACL is no longer associated with any subnet. The AWS CLI can list associations:

# Verify NACL associations
aws ec2 describe-network-acls \
  --filters Name=vpc-id,Values=$(terraform output -raw vpc_id) \
  --query "NetworkAcls[*].{ID:NetworkAclId,Assoc:Associations}"

Attempt to reach a private service from a compromised EC2 instance using curl or nc. The connection should be refused unless you have an explicit allow rule.

# From a compromised host (simulated)
nc -vz 10.0.2.10 3306   # Expected: Connection refused
nc -vz 10.0.2.10 22     # Expected: Connection open (if allow_ssh_in rule exists)

These tests confirm that the deny‑all rule is effective and that only the intended traffic passes.

Security and Best Practices

Version control. Store the Terraform files in a dedicated Git repository with branch protection rules. Treat NACL changes as code changes that require peer review.

Continuous validation. Integrate a policy‑as‑code tool such as terraform-compliance or OPA to enforce that every NACL rule includes an explicit allow or deny and that no “allow all” rule sneaks in.

Auditing. Enable VPC Flow Logs and pipe them to CloudWatch Logs or an S3 bucket. A simple CloudWatch Metric Filter can alert when traffic is denied, helping you detect misconfigurations quickly.

Related Insights

Continue exploring Cloud & DevOps: