AWS for DevOps — Security Groups vs NACLs
4.4 — Security Groups vs NACLs
AWS gives you two layers of network firewall: Security Groups (instance-level) and NACLs (subnet-level). Understanding the differences is critical for designing secure architectures.
Comparison Table
| Feature | Security Group | NACL |
|---|---|---|
| Level | Instance (ENI) | Subnet |
| State | Stateful — return traffic auto-allowed | Stateless — must allow return traffic explicitly |
| Rules | Allow only | Allow and Deny |
| Rule order | All rules evaluated together | Rules evaluated in order (lowest number first) |
| Default | Deny all inbound, allow all outbound | Allow all inbound and outbound |
| Applied to | Instances you specify | All instances in the subnet |
| Typical use | Fine-grained per-service control | Broad subnet-level guardrails |
Stateful vs Stateless — The Key Difference
SECURITY GROUP (Stateful):
┌───────────────────────────────────┐
│ Inbound Rule: Allow port 443 │
│ │
│ Client ──443──► EC2 │
│ Client ◄──────── EC2 │ ← Response auto-allowed
└───────────────────────────────────┘
NACL (Stateless):
┌───────────────────────────────────┐
│ Inbound Rule: Allow port 443 │
│ Outbound Rule: ??? │
│ │
│ Client ──443──► EC2 ✓ │
│ Client ◄──────── EC2 ✗ BLOCKED │ ← Must explicitly allow
└───────────────────────────────────┘
With NACLs, you must add an outbound rule for ephemeral ports (1024-65535) so that return traffic is allowed.
NACL Rule Structure
Rule # | Type | Protocol | Port Range | Source/Dest | Allow/Deny
-------+---------+----------+-------------+----------------+-----------
100 | HTTPS | TCP | 443 | 0.0.0.0/0 | ALLOW
110 | SSH | TCP | 22 | 10.0.0.0/16 | ALLOW
120 | Custom | TCP | 1024-65535 | 0.0.0.0/0 | ALLOW
200 | All | All | All | 203.0.113.50/32| DENY
* | All | All | All | 0.0.0.0/0 | DENY
- Rules are evaluated in order — lowest number first
- First matching rule wins
*is the default catch-all rule (cannot be removed)
Creating a NACL
# Create a NACL
$ aws ec2 create-network-acl \
--vpc-id vpc-0abc123 \
--tag-specifications 'ResourceType=network-acl,Tags=[{Key=Name,Value=public-nacl}]'
{
"NetworkAcl": {
"NetworkAclId": "acl-pub123"
}
}
# Add inbound rule: allow HTTPS from anywhere
$ aws ec2 create-network-acl-entry \
--network-acl-id acl-pub123 \
--rule-number 100 \
--protocol tcp \
--port-range From=443,To=443 \
--cidr-block 0.0.0.0/0 \
--rule-action allow \
--ingress
# Add inbound rule: allow ephemeral ports (for return traffic)
$ aws ec2 create-network-acl-entry \
--network-acl-id acl-pub123 \
--rule-number 110 \
--protocol tcp \
--port-range From=1024,To=65535 \
--cidr-block 0.0.0.0/0 \
--rule-action allow \
--ingress
# Add outbound rule: allow all traffic
$ aws ec2 create-network-acl-entry \
--network-acl-id acl-pub123 \
--rule-number 100 \
--protocol -1 \
--cidr-block 0.0.0.0/0 \
--rule-action allow \
--egress
# Associate with a subnet
$ aws ec2 replace-network-acl-association \
--association-id aclassoc-old123 \
--network-acl-id acl-pub123
When to Use Which
| Scenario | Use |
|---|---|
| Control traffic to a specific instance | Security Group |
| Allow app tier to talk to DB tier | Security Group (SG chaining) |
| Block a specific IP address | NACL (Deny rule) |
| Subnet-level compliance guardrail | NACL |
| Deny traffic from a known malicious CIDR | NACL |
| Allow/deny per service | Security Group |
Defence in Depth Pattern
Use both together:
Internet
│
▼
┌── NACL (subnet level) ────────────┐
│ Allow 443 inbound │
│ Deny 203.0.113.50/32 (bad actor) │
│ │
│ ┌── Security Group (instance) ─┐ │
│ │ Allow 443 from 0.0.0.0/0 │ │
│ │ Allow 22 from bastion-sg │ │
│ │ │ │
│ │ ┌──────────┐ │ │
│ │ │ EC2 │ │ │
│ │ └──────────┘ │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
Blocking a Specific IP with NACL
# Deny all traffic from a malicious IP (rule 50 — before allow rules)
$ aws ec2 create-network-acl-entry \
--network-acl-id acl-pub123 \
--rule-number 50 \
--protocol -1 \
--cidr-block 203.0.113.50/32 \
--rule-action deny \
--ingress
Tip: Use a low rule number (50) so the Deny is evaluated before any Allow rules.
Common Mistakes
| Mistake | Why It Hurts | Fix |
|---|---|---|
| Forgetting NACL outbound ephemeral ports | Return traffic is blocked | Add outbound rule for ports 1024-65535 |
| Using only NACLs (no security groups) | Coarse-grained, hard to manage per service | Use SGs for per-instance, NACLs for subnet guardrails |
| Adding NACL rules with wrong order | Higher-numbered allow overrides nothing if deny is first | Plan rule numbers carefully |
| Not understanding stateless vs stateful | Unexpected traffic blocks | Remember: SG = stateful, NACL = stateless |
Quick Reference
| Task | Command |
|---|---|
| Create NACL | aws ec2 create-network-acl --vpc-id VPC |
| Add inbound rule | aws ec2 create-network-acl-entry --network-acl-id ACL --rule-number N --ingress ... |
| Add outbound rule | aws ec2 create-network-acl-entry --network-acl-id ACL --rule-number N --egress ... |
| List NACLs | aws ec2 describe-network-acls --filters Name=vpc-id,Values=VPC |
| Delete rule | aws ec2 delete-network-acl-entry --network-acl-id ACL --rule-number N --ingress |
Next → VPC Peering & Endpoints