Ch. 15 · AWS

AWS Security Groups Versus Network ACLs

Security groups are stateful and sit on interfaces; network ACLs are stateless, ordered and sit on subnets. What that means for reply traffic.

~7 min readbeginnerupdated Oct 7, 2026

Both security groups and network ACLs filter traffic in a VPC, and both have rules that mention ports and CIDRs, so they get described as “two firewalls”. The differences between them are exactly what interviewers ask about, and they’re not cosmetic. One remembers connections and the other doesn’t. One only allows and the other can deny. One sits on a network interface and the other on a subnet boundary. Each of those changes how you write rules and how things break.

Where each one sits

A security group is attached to an elastic network interface, which in practice means an EC2 instance, an RDS database, a Lambda function in a VPC, a load balancer or an ECS task. An interface can have several security groups (five by default), and the effective rules are the union of all of them. Two instances in the same subnet can have completely different security groups.

A network ACL is attached to a subnet. Every interface in that subnet is behind the same NACL, and the NACL only sees traffic crossing the subnet boundary. Two instances in the same subnet talking to each other don’t pass through it at all.

So the picture for a packet from the internet to a web server is: internet gateway, then the subnet’s NACL inbound rules, then the instance’s security group inbound rules, then the instance. The reply leaves through the security group, then the NACL’s outbound rules.

Stateful versus stateless, with a real reply

This is the central difference. A security group tracks connections. If an inbound rule allows a request, the reply is allowed out regardless of outbound rules, and if an outbound rule allows a request, its reply is allowed back in. You think in terms of “who may start a conversation”.

A NACL evaluates every packet on its own. It has no idea that an outbound packet is a reply to something it allowed in a moment ago. So you must allow both directions explicitly, and the reply direction is the one people get wrong because the port isn’t the one they think of.

When a client connects to your server on 443, the client’s side of the connection uses a random ephemeral port, for example 51544. The reply goes from your server’s port 443 to the client’s port 51544. An outbound NACL rule that allows “port 443” is matching on the destination port, which is 51544, so it doesn’t match.

Here’s a small Python model of one HTTPS request and its reply. It only looks at destination ports (real rules also match protocol and CIDR), but it follows the real evaluation rules: NACL rules sorted by number, first match wins, implicit deny at the end; the security group allowing replies on tracked connections.

# Toy model: a client at 203.0.113.10 calls a web server at 10.0.1.25:443.
# The NACL on the server's subnet is stateless; the security group is stateful.
def nacl(rules, port):
    for num, lo, hi, action in sorted(rules):          # lowest rule number first, first match wins
        if lo <= port <= hi:
            return f"rule {num} {action}"
    return "rule * DENY"

sg_inbound = {443}                                     # allow-only; replies are tracked automatically
nacl_in  = [(100, 443, 443, "ALLOW")]
nacl_out_tight = [(100, 443, 443, "ALLOW")]            # looks symmetrical, is wrong
nacl_out_fixed = [(100, 443, 443, "ALLOW"), (110, 1024, 65535, "ALLOW")]

request_port, reply_port = 443, 51544                  # the client picked 51544 as its source port
print("request  inbound  NACL:", nacl(nacl_in, request_port), "| SG:", "ALLOW" if request_port in sg_inbound else "DENY")
for name, out in (("tight", nacl_out_tight), ("fixed", nacl_out_fixed)):
    print(f"reply    outbound NACL ({name}):", nacl(out, reply_port), "| SG: ALLOW (tracked connection)")

# rule order matters: a DENY with a lower number beats a later ALLOW
blocklist = [(90, 0, 65535, "DENY"), (100, 443, 443, "ALLOW")]
print("with rule 90 DENY first:", nacl(blocklist, 443))
python

Output from python3 sgnacl.py:

request  inbound  NACL: rule 100 ALLOW | SG: ALLOW
reply    outbound NACL (tight): rule * DENY | SG: ALLOW (tracked connection)
reply    outbound NACL (fixed): rule 110 ALLOW | SG: ALLOW (tracked connection)
with rule 90 DENY first: rule 90 DENY
Text

With the “tight” NACL, the request gets in, the server processes it, and the reply is dropped at the subnet boundary. The client sees a connection that hangs and times out, not a refusal, which is why NACL mistakes are slow to diagnose. The fix allows outbound to the ephemeral range. Clients choose ephemeral ports from different ranges (Linux typically uses 32768-60999, Windows 49152-65535, and NAT gateways and load balancers use 1024-65535), so AWS’s guidance is to open 1024-65535 when you don’t control the clients.

The same applies in the other direction. If an instance in the subnet calls an external API on 443, the outbound NACL needs 443 and the inbound NACL needs the ephemeral range for the replies.

Rules: allow-only versus ordered allow and deny

Security group rules can only allow. There’s no deny rule and no order: AWS evaluates all rules, and if any of them permits the traffic, it’s allowed. A new security group has no inbound rules (so it denies all inbound) and one outbound rule allowing everything.

NACL rules have numbers, are evaluated from lowest to highest, and the first matching rule decides, whether it’s ALLOW or DENY. The last line of every NACL is the * rule, which denies anything that matched nothing. The last row of the output above is the case to remember: a deny at rule 90 wins over an allow at rule 100, and if you’d numbered them the other way round, the allow would have won and the deny would never be reached. Leave gaps (100, 110, 120) so you can insert rules later.

The VPC’s default NACL allows all traffic in both directions, which is why many accounts never touch NACLs. A custom NACL you create starts with only the * deny rules, so a freshly associated custom NACL blocks everything until you add rules.

That’s also the honest answer to “when do you use NACLs?”: for coarse, subnet-wide guardrails, especially blocking. If a specific address range is attacking you, a low-numbered NACL DENY drops it for the whole subnet, which a security group can’t express. Day-to-day access control belongs in security groups.

Security groups that reference each other

The most useful thing security groups do is reference other security groups instead of IP addresses. A three-tier setup looks like this:

sg-alb   inbound: 443 from 0.0.0.0/0
sg-app   inbound: 8080 from sg-alb
sg-db    inbound: 5432 from sg-app
Text

“5432 from sg-app” means “from any interface that has sg-app attached”. When autoscaling launches ten more app servers with new private IPs, the database accepts them without any rule change. An IP-based rule (10.0.11.0/24) would allow everything in that subnet, including unrelated workloads, and still need editing when a new subnet is added.

A common mistake in interviews is saying you’d “lock down the database with a NACL”. NACLs can only see CIDRs, so they can’t express “only the app servers”, and the stateless rules make them harder to get right. Use sg-db as above, and keep NACLs broad.

Checking which one blocked you

VPC Flow Logs record each flow at an interface with an ACCEPT or REJECT. Because security groups are stateful, a rejected reply for an accepted request is a strong hint that a NACL dropped it: look for an ACCEPT inbound on 443 followed by a REJECT outbound to a high port. VPC Reachability Analyzer goes further and names the specific security group or NACL rule that blocks a path.

Interview practice: the database that times out from one subnet

An app team moves their service from subnet A to a new subnet C. Both subnets can reach the internet. From C, connections to the RDS database (in subnet B, port 5432) hang and time out. The database security group allows 5432 from sg-app, and the new instances have sg-app. Subnet C has a custom NACL that allows outbound 5432 and inbound 443. Diagnose it.

Answer

The security group is fine: sg-app is attached, and it’s referenced by ID, so the new IPs don’t matter. A timeout rather than a refusal points to dropped packets, and the only new thing is subnet C’s custom NACL.

Follow the connection. The app server in C sends to B on 5432 from an ephemeral port, say 40112. C’s outbound NACL allows 5432, so that leaves. B’s NACL (probably the default) lets it in, the database replies from 5432 to port 40112, and the reply arrives at subnet C’s inbound NACL. That NACL only allows inbound 443, so the reply hits the * deny. The connection never completes.

The fix is an inbound rule on C’s NACL allowing TCP 1024-65535 from B’s CIDR (or the VPC CIDR). I’d also check the outbound rules for the same issue on anything C serves, and mention that if there’s no reason for a custom NACL on C, removing it in favour of the default plus tight security groups is simpler. Flow logs would confirm it: an outbound ACCEPT to 5432 and an inbound REJECT from 5432 on the app server’s interface.

Keep going

More in AWS

read ✓AWS · easy

AWS NAT Gateways and Private Outbound Access

How a NAT gateway gives private subnets outbound internet access, why you want one per Availability Zone, and where its data charges come from.

~6 min readread →
read ✓AWS · easy

AWS VPC Routes and Network Reachability

How VPC route tables pick a target by longest prefix, what makes a subnet public or private, and how to debug an instance that can't connect.

~6 min readread →
esc