Network ACLs vs Security Groups: When to Use

Understand the differences between Network ACLs and Security Groups to implement effective multi-layer network security.

Understanding the differences between Network ACLs and Security Groups is crucial for implementing effective multi-layer network security in AWS VPCs.

Key Differences

Comparison Table

Feature Security Groups Network ACLs
Level Instance (ENI) Subnet
State Stateful Stateless
Rules Allow only Allow and Deny
Evaluation All rules Rules in order
Default Deny all inbound Allow all

Security Groups

Stateful Configuration

WebServerSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: Web server security group
    VpcId: !Ref VPC
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        SourceSecurityGroupId: !Ref ALBSecurityGroup
      - IpProtocol: tcp
        FromPort: 80
        ToPort: 80
        SourceSecurityGroupId: !Ref ALBSecurityGroup
    SecurityGroupEgress:
      - IpProtocol: tcp
        FromPort: 5432
        ToPort: 5432
        DestinationSecurityGroupId: !Ref DatabaseSecurityGroup
      - IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        CidrIp: 0.0.0.0/0

Security Group Chaining

ALBSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: ALB accepts traffic from internet
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        CidrIp: 0.0.0.0/0

AppSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: App accepts traffic only from ALB
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 8080
        ToPort: 8080
        SourceSecurityGroupId: !Ref ALBSecurityGroup

DatabaseSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: DB accepts traffic only from App
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 5432
        ToPort: 5432
        SourceSecurityGroupId: !Ref AppSecurityGroup

Network ACLs

Stateless Configuration

PublicSubnetNACL:
  Type: AWS::EC2::NetworkAcl
  Properties:
    VpcId: !Ref VPC

# Inbound Rules
InboundHTTPS:
  Type: AWS::EC2::NetworkAclEntry
  Properties:
    NetworkAclId: !Ref PublicSubnetNACL
    RuleNumber: 100
    Protocol: 6  # TCP
    RuleAction: allow
    CidrBlock: 0.0.0.0/0
    PortRange:
      From: 443
      To: 443

InboundEphemeral:
  Type: AWS::EC2::NetworkAclEntry
  Properties:
    NetworkAclId: !Ref PublicSubnetNACL
    RuleNumber: 200
    Protocol: 6
    RuleAction: allow
    CidrBlock: 0.0.0.0/0
    PortRange:
      From: 1024
      To: 65535

# Outbound Rules
OutboundHTTPS:
  Type: AWS::EC2::NetworkAclEntry
  Properties:
    NetworkAclId: !Ref PublicSubnetNACL
    RuleNumber: 100
    Protocol: 6
    Egress: true
    RuleAction: allow
    CidrBlock: 0.0.0.0/0
    PortRange:
      From: 443
      To: 443

OutboundEphemeral:
  Type: AWS::EC2::NetworkAclEntry
  Properties:
    NetworkAclId: !Ref PublicSubnetNACL
    RuleNumber: 200
    Protocol: 6
    Egress: true
    RuleAction: allow
    CidrBlock: 0.0.0.0/0
    PortRange:
      From: 1024
      To: 65535

Blocking Specific IPs

BlockMaliciousIP:
  Type: AWS::EC2::NetworkAclEntry
  Properties:
    NetworkAclId: !Ref PublicSubnetNACL
    RuleNumber: 50  # Lower number = higher priority
    Protocol: -1  # All protocols
    RuleAction: deny
    CidrBlock: 192.0.2.0/24  # Malicious IP range

Defense in Depth Strategy

Layered Security

Internet
    │
    ▼
┌─────────────────┐
│   WAF Rules     │  ← Application layer filtering
└────────┬────────┘
         │
    ▼
┌─────────────────┐
│  Network ACL    │  ← Subnet-level deny rules
│  (Stateless)    │
└────────┬────────┘
         │
    ▼
┌─────────────────┐
│ Security Group  │  ← Instance-level allow rules
│  (Stateful)     │
└────────┬────────┘
         │
    ▼
┌─────────────────┐
│    Instance     │
└─────────────────┘

Implementation Script

import boto3

ec2 = boto3.client('ec2')

def implement_defense_in_depth(vpc_id, subnet_id):
    # Create NACL for blocking known bad actors
    nacl = ec2.create_network_acl(VpcId=vpc_id)
    nacl_id = nacl['NetworkAcl']['NetworkAclId']
    
    # Block known malicious ranges
    malicious_ranges = ['192.0.2.0/24', '198.51.100.0/24']
    for i, cidr in enumerate(malicious_ranges):
        ec2.create_network_acl_entry(
            NetworkAclId=nacl_id,
            RuleNumber=(i + 1) * 10,
            Protocol='-1',
            RuleAction='deny',
            CidrBlock=cidr,
            Egress=False
        )
    
    # Allow all other traffic (let SGs handle fine-grained control)
    ec2.create_network_acl_entry(
        NetworkAclId=nacl_id,
        RuleNumber=32766,
        Protocol='-1',
        RuleAction='allow',
        CidrBlock='0.0.0.0/0',
        Egress=False
    )
    
    # Associate with subnet
    associations = ec2.describe_network_acls(
        Filters=[{'Name': 'association.subnet-id', 'Values': [subnet_id]}]
    )
    
    if associations['NetworkAcls']:
        assoc_id = next(
            a['NetworkAclAssociationId'] 
            for a in associations['NetworkAcls'][0]['Associations']
            if a['SubnetId'] == subnet_id
        )
        ec2.replace_network_acl_association(
            AssociationId=assoc_id,
            NetworkAclId=nacl_id
        )
    
    return nacl_id

Working with Warqline

We are a cloud engineering consultancy and an official AWS and Google Cloud partner. If you are running this in production and want a second pair of eyes, we scope work in a free 45-minute technical call: you describe what you are running and what worries you, and we tell you what we would look at first.

Talk to an engineer

Conclusion

Use Security Groups for instance-level, stateful filtering with allow rules. Use Network ACLs for subnet-level, stateless filtering when you need explicit deny rules. Combine both for effective defense in depth.