AWS for DevOps — Security Groups vs NACLs

3 minute read

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

Updated: