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.
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.