Skip to content

ELB load balancers should prohibit public access

A public-facing load balancer puts an internet-resolvable DNS name in front of your backend compute. Security groups help, but the attack surface is still larger: misconfigurations, overly permissive listener rules, or a zero-day in the application stack can expose services that were never meant to be internet-accessible.

Internal load balancers restrict ingress to the VPC and peered networks. Public access, where genuinely needed, runs through explicitly provisioned entry points like API Gateway or CloudFront with origin access controls, not through a load balancer with a public IP.

Retrofit consideration

Changing a load balancer from internet-facing to internal is a destructive change in Terraform (ForceNew). The resource gets replaced, the DNS name changes, and every consumer pointing at the old name breaks. If public access is genuinely needed, provision a new internal ALB behind API Gateway or CloudFront rather than toggling the existing one.

Implementation

Choose the approach that matches how you manage Terraform.

If you use terraform-aws-modules/alb/aws, set the right module inputs for this control. You can later migrate to the compliance.tf module with minimal changes because it is compatible by design.

module "alb" {
  source  = "terraform-aws-modules/alb/aws"
  version = ">=10.0.0,<11.0.0"

  access_logs = {
    bucket  = "example-bucket-abc123"
    enabled = true
  }
  listeners = {
    https = {
      certificate_arn = "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"
      forward = {
        target_group_key = "default"
      }
      protocol   = "HTTPS"
      ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
    }
  }
  load_balancer_type = "application"
  name               = "abc123"
  security_groups    = ["sg-abc12345"]
  subnets            = ["subnet-abc123", "subnet-def456"]
  target_groups = {
    default = {
      create_attachment = false
      name_prefix       = "def-"
      port              = 443
      protocol          = "HTTPS"
      target_type       = "ip"
    }
  }
  vpc_id = "vpc-12345678"

  internal = true
}

Use AWS provider resources directly. See docs for the resources involved: aws_lb.

resource "aws_lb" "this" {
  access_logs {
    bucket = "pofix-logs-abc123"
  }
  name    = "pofix-abc123"
  subnets = ["subnet-abc123", "subnet-def456"]

  internal = true
}

What this control checks

aws_lb covers ALBs and NLBs: internal must be set to true. When internal is false or omitted (the default is false), the load balancer is internet-facing and fails this control. aws_elb for Classic Load Balancers uses the same argument with the same requirement. The underlying check is whether the scheme attribute resolves to "internal" rather than "internet-facing". Any load balancer with a publicly resolvable DNS name fails. Check that subnets referenced in subnets or subnet_mapping are private subnets with no routes to an internet gateway. Placing an internal load balancer in a public subnet is technically valid but undermines the network-segmentation goal this control is meant to enforce.

Common pitfalls

  • Default scheme is internet-facing

    The internal argument defaults to false on both aws_lb and aws_elb. Omit it and you get a public-facing load balancer. Always set internal = true explicitly.

  • Replacement required on scheme change

    Changing internal from false to true on an existing aws_lb triggers a destroy-and-recreate (ForceNew), which changes the DNS name and breaks any consumer that references it. Plan a blue-green migration rather than an in-place toggle.

  • Internal ALB in public subnet

    Use private subnets for internal load balancers. Terraform won't stop you from placing an internal load balancer in a public subnet (one with an internet gateway route), but it muddies network segmentation and gives auditors reviewing subnet topology a reasonable question to ask.

  • Gateway Load Balancers excluded

    Gateway Load Balancers (aws_lb with load_balancer_type = "gateway") don't support the internal argument. This control typically excludes them, but verify your compliance scanner's scope before assuming they pass automatically.

Audit evidence

AWS Config rule evaluation results showing all ELB resources as compliant are the primary artifact. The EC2 Load Balancers console has a 'Scheme' column; a screenshot or export filtered to the relevant accounts and regions works as point-in-time evidence.

For continuous coverage, provide Config conformance pack or Security Hub findings with historical compliance status. CloudTrail logs for CreateLoadBalancer calls can confirm the Scheme parameter was internal at provisioning time. Where exceptions exist for legitimately public-facing load balancers, include documented risk acceptance and compensating controls (WAF association, security group restrictions).

Framework-specific interpretation

Tool mappings

Use these identifiers to cross-reference this control across tools, reports, and evidence.

  • Compliance.tf Control: elb_application_classic_network_lb_prohibit_public_access

  • Powerpipe Control: aws_compliance.control.elb_application_classic_network_lb_prohibit_public_access

  • Prowler Checks: elb_internet_facing, elbv2_internet_facing

  • Trivy Check: AWS-0053

Last reviewed: 2026-03-09