The question this answers
When is a coarse, stateless, subnet-wide filter worth adding on top of stateful per-resource rules?
A compliance requirement says a specific range of addresses must be blocked from reaching an entire environment, and the block must hold regardless of what any individual team configures on their own resources.
A subnet-wide filter that no per-resource rule can override, evaluated before and after the stateful layer, and capable of expressing an explicit deny — which a security group cannot.
Stateless is the whole difference
A network ACL sits at the subnet boundary and evaluates every packet crossing it, in rule-number order, with the first match winning and both allow and deny available. It has no memory of connections. A packet leaving the subnet and the packet coming back are two unrelated events, each matched independently against the inbound and outbound rule lists.
The practical consequence is the thing that catches people. Permit inbound TCP 443 and outbound nothing, and a request arrives, the server processes it, and the response is dropped on its way out — so the client sees a hang rather than an error. Permit outbound 443 and forget inbound, and every outbound API call the subnet makes appears to succeed and then times out, because the response is dropped on the way in.
The return traffic does not arrive on port 443. It arrives on the *ephemeral* port the client chose, which is why every ACL that allows a service also needs a rule permitting the ephemeral range in the opposite direction. This is the single most common ACL mistake, it produces symptoms that look exactly like packet loss, and it is entirely absent from the stateful world of Security Groups: The Stateful Firewall.
| Dimension | Security group (stateful) | Network ACL (stateless) |
|---|---|---|
| Attaches to | A resource's network interface. | A subnet — every resource in it, without exception. |
| Rule types | Allow only. | Allow and deny, which is the one thing a group cannot express. |
| Evaluation | Union of all matching rules; no ordering. | Rule number order, first match wins — ordering is load-bearing. |
| Return traffic | Permitted automatically for an allowed connection. | Requires its own explicit rule, on the ephemeral port range. |
| Can reference a group | Yes — the property that makes rules durable. | No. CIDRs only. |
| Typical failure | Missing rule: a clean, symmetric hang. | Missing return rule: one-directional connectivity that looks like packet loss. |
| Right job | Everyday segmentation between tiers. | A coarse, non-overridable block at the subnet edge. |
The rule set that actually works
Below is a minimal correct ACL for a subnet hosting a public entry point. Note that the outbound list is not a mirror of the inbound list — it permits the *ephemeral* range, because that is where responses go. Note also the deny row at rule 90, placed before the allows, since first-match-wins means a deny after an allow never fires.
The ordering requirement is a genuine trap. Security groups have no ordering, so an engineer fluent in groups will write ACL rules in whatever order seems logical and produce a rule set that does not do what it reads like. Rule numbers should be spaced generously — increments of ten or a hundred — so that a rule can be inserted between two existing ones without renumbering the list.
Keep the list short. An ACL with forty rules is one that nobody will audit and that will eventually block something important on a Sunday. If a policy needs that much nuance, it belongs in security groups where it can reference workload identity, or in an egress proxy where it can reference hostnames.
NETWORK ACL acl-public-a associated subnets: public-a, public-b
INBOUND
# RULE ACTION WHY
90 203.0.113.0/24 ALL DENY compliance block; must be
first, or the allow wins
100 0.0.0.0/0 TCP 443 ALLOW public entry point
110 0.0.0.0/0 TCP 1024-65535 ALLOW <- RESPONSES to outbound
calls. Forget this and every
outbound API call hangs.
* 0.0.0.0/0 ALL DENY implicit final rule
OUTBOUND
# RULE ACTION WHY
100 0.0.0.0/0 TCP 443 ALLOW outbound API and package calls
110 0.0.0.0/0 TCP 1024-65535 ALLOW <- RESPONSES to inbound
requests. Forget this and the
site accepts connections and
never answers.
* 0.0.0.0/0 ALL DENY implicit final rule
FIRST MATCH WINS. A deny placed after an allow never fires.
The ephemeral range differs by client OS; 1024-65535 is the safe superset.When it earns its place — and when it does not
The honest default is that most environments do not need custom network ACLs. The provider's default ACL permits everything, security groups do the real segmentation, and adding a stateless layer adds a second place to look during every future outage. That is a genuine cost: the number one reason to hesitate is that the failure mode is asymmetric, silent and easily mistaken for a network problem.
It earns its place in three situations. First, when a block must not be overridable by a resource owner — an ACL is the only construct here that expresses deny and that a team cannot bypass by editing their own group. Second, when a whole subnet must be isolated quickly during an incident, where a single ACL change is far faster than editing every group. Third, when a compliance control explicitly requires filtering at the subnet boundary, which some regulated environments do.
Outside those, prefer the stateful layer. And whichever you use, the guard rail is the same: an ACL change should be reviewed as code with the return-traffic rule visible in the diff, because "we added a deny and half the environment went one-directional" is a real Sunday.
Key points
- Network ACLs are subnet-wide, ordered, allow-and-deny, and stateless — return traffic needs its own explicit rule.
- Responses arrive on the client's ephemeral port, so an ACL that allows a service must also allow the ephemeral range in the other direction.
- First match wins, so a deny placed after an allow never fires — the opposite of the unordered security-group model.
- They are the only construct here that can express a deny a resource owner cannot override, which is their real reason to exist.
- Most environments should leave the default permissive ACL alone and do segmentation with security groups.
The loop, answered
Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.
- • The ACL is associated with a subnet and evaluates every packet crossing that boundary in either direction.
- • Inbound and outbound rule lists are separate and are matched independently, in ascending rule-number order.
- • The first matching rule decides; an implicit deny-all terminates each list.
- • No connection state is kept, so a request and its response are two unrelated evaluations.
- • The ACL is evaluated in addition to security groups, so traffic must be permitted by both layers to pass.
- • Own rule numbering with generous gaps so a rule can be inserted without renumbering.
- • Own the paired rule discipline: every service rule needs its ephemeral-range counterpart in the opposite list.
- • Own the rule count — keep it short enough that a human can verify it during an incident.
- • Own ACL changes as reviewed code, with the return-traffic rule visible in the same diff.
- • Own the decision to have a custom ACL at all, and revisit it; an inherited one that nobody understands is worse than none.
- • Missing ephemeral-range rule: connections establish and then hang, which is diagnosed as packet loss or application slowness for hours.
- • A deny rule numbered after an allow, so it silently never applies and the block everyone believes exists does not.
- • A subnet-wide change breaking one workload nobody remembered was in that subnet.
- • Blocked path-MTU-discovery or ICMP, producing large transfers that stall while small requests work perfectly.
- • An emergency isolation ACL left in place after the incident, quietly blocking a dependency added later.
- • Rule-count quotas per ACL are low by design; a long list is a signal that the wrong layer is being used.
- • The blast radius grows with the number of workloads in the subnet, so a busy shared subnet is the worst place for a nuanced ACL.
- • Because rules are CIDR-only, they do not scale with dynamic membership the way group references do.
- • Evaluation cost is negligible; reviewability is the constraint that actually binds.
- • The only construct in this module that expresses an explicit deny and cannot be overridden by a resource owner.
- • Useful as a coarse, fast isolation lever during an incident, where changing one object beats changing dozens.
- • It cannot reference workload identity, so it is a blunt instrument compared with group-referenced rules.
- • Two layers is genuine defence in depth — and genuinely two places to look when something is unreachable. See Defense in Depth.
- • Free on every major provider; there is no meter attached to the object.
- • The real cost is diagnostic time: an asymmetric ACL failure is among the most expensive-to-diagnose network faults in cloud.
- • A second cost is change friction — every future firewall change now has two places to be correct.
- • The counterfactual cost matters too: an unenforceable compliance control that fails an audit is expensive in a different currency.
- • Flow logs, which record accepts and rejects at the subnet boundary and are the only practical way to see an ACL drop.
- • One-directional traffic patterns — outbound bytes with no matching inbound — which is the ACL fingerprint.
- • ACL rule-change events, correlated with the moment connectivity became strange.
- • The signal that lies: a successful TCP handshake. It can complete while the response to the first request is dropped, so "the port is open" proves nothing about whether the connection works.
- • The provider's default permissive ACL plus well-designed security groups — the right answer for most environments.
- • A security group with narrow rules, whenever the policy can be expressed as an allow; it is stateful and can reference groups.
- • An egress proxy with a hostname allow-list, when outbound control is the real requirement — an ACL cannot express hostnames.
- • A separate subnet or a separate network, when the requirement is really isolation rather than filtering.
- • Buys a non-overridable, subnet-wide control including explicit denies; costs statelessness and a second layer in every future diagnosis.
- • Buys fast, coarse isolation during an incident; costs a blast radius that includes everything sharing the subnet.
- • Buys an auditable compliance control; costs rule-ordering complexity that is easy to get subtly and silently wrong.
What people believe, and what is true
A network ACL is just a subnet-level security group.
It is stateless and ordered, and it supports deny. Those three differences change how every rule must be written and how every failure presents.
If I allow port 443 in both directions, traffic works.
Responses arrive on the client's ephemeral port, not on 443. Without the ephemeral-range rule, connections establish and then hang.
Adding an ACL makes the environment more secure.
It adds a control and a failure mode. If security groups already express the policy, the ACL adds diagnostic cost without adding protection — unless the requirement is a deny nobody can override.