Security Groups vs. Network ACLs
Concept
A VPC has two independent layers of packet-filtering, and they answer different questions. A security group is attached to a resource (an EC2 instance's network interface, an RDS instance, a Lambda's ENI) and is stateful: allow an inbound connection on a rule, and the matching outbound response traffic is automatically permitted, with no separate outbound rule required. A network ACL (NACL) is attached to a subnet and is stateless: every direction of every connection needs its own explicit rule, because the NACL evaluates each packet independently with no memory of the connection it belongs to.
That statelessness is the entire reason NACLs are harder to get right. A security group rule allowing inbound TCP 443 from anywhere just works — the response leaves automatically. The equivalent NACL rule needs an inbound-allow on 443 and an outbound-allow on the ephemeral port range (1024-65535, where the client's return traffic lands), because the NACL has no concept of "this is the response to that." Miss the ephemeral-port outbound rule and the request appears to leave fine, gets a response from the server, and that response is silently dropped at the subnet boundary — a failure mode that looks identical to a routing problem or a security-group misconfiguration from the outside, but isn't either.
The two layers also differ in what they can express. Security groups only support allow rules — anything not explicitly allowed is denied, and there's no way to write an explicit deny (you can't use a security group to block one specific bad actor while allowing the rest of the internet). NACLs support both allow and deny rules, evaluated in numbered order until the first match — which is the one thing NACLs can do that security groups structurally cannot: block a specific CIDR range while leaving a broader allow rule in place beneath it.
Tradeoffs
| Dimension | Security Groups | Network ACLs |
|---|---|---|
| Attachment point | Resource (ENI) | Subnet |
| State tracking | Stateful — return traffic auto-allowed | Stateless — every direction needs an explicit rule |
| Rule types | Allow only | Allow and explicit deny |
| Evaluation | All rules evaluated, most permissive wins | Numbered rules, first match wins (order matters) |
| Default posture | Deny all inbound, allow all outbound | Default NACL allows all; custom NACLs deny all until rules added |
| Granularity | Per-instance, can reference other security groups by ID | Per-subnet — same rule applies to everything in that subnet |
| Typical role | Primary, day-to-day access control | Coarse-grained backstop / defense-in-depth layer |
When to use / when not to
- Security groups are the default, primary control for almost everything — they're simpler to reason about (stateful, allow-only, referenceable by security-group ID instead of hardcoded CIDRs), and referencing another security group as the source (rather than an IP range) keeps rules correct even as instances scale up/down or get replaced.
- Reach for a NACL when the need is genuinely subnet-wide and coarse: blocking a known-bad CIDR range at the subnet boundary regardless of which security group any given instance has, or as a second layer of defense so a single misconfigured security group isn't the only thing standing between a subnet and the internet.
- Don't use a NACL as the primary access-control mechanism for anything that needs per-resource granularity — a NACL rule applies to every resource in that subnet uniformly, so it can't express "this instance can receive traffic on 5432, that one can't" the way a security group naturally does.
- Layering both (security group as primary, custom NACL as a coarse backstop) is a defense-in-depth pattern specifically because they fail differently: a security group misconfiguration doesn't help you if the NACL also allows the bad traffic, and vice versa.
Common pitfall
Assuming a correctly-routed request that reaches a resource but gets no response is a security-group problem, when it's actually a NACL missing the ephemeral-port return-traffic rule. Because security groups are stateful and NACLs aren't, the debugging instinct that works for security groups (just check the inbound rule) doesn't transfer — a NACL needs both directions checked independently, and the outbound ephemeral-port range specifically is the rule most often forgotten because it has no obvious inbound counterpart to remind you it's needed. The fix is procedural: when a NACL is in play, always verify inbound and outbound rules for both the service port and the ephemeral-port range, rather than checking only the direction that matches the traffic that appears to be failing.
Engineering Lens
The useful design-review question isn't "do we have security groups configured" — that's assumed — it's whether a custom NACL is deliberately layered in as a second, independently-failing control for anything crossing a genuine trust boundary (a subnet holding regulated data, a DMZ-style public subnet in front of private ones), and whether whoever configured that NACL understands the ephemeral-port requirement well enough that it doesn't silently break legitimate traffic six months later when nobody remembers a NACL is even in the picture. Security groups alone are usually sufficient; the decision to add NACL-level defense-in-depth should be a deliberate call about blast radius, not a default checked on every subnet out of caution.