Filter concepts by levelShowing all levels.

AWS · Section 9

Security Groups and Network ACLs

Level
intermediate
Read
28 min
Concepts
5

Security groups and network ACLs both filter VPC traffic, but with almost opposite defaults: a security group is stateful, allow-only, and attached to a resource; a network ACL is stateless, can allow or deny, is attached to a subnet, and evaluates numbered rules in order. This section covers each layer's actual behavior, when to reach for which one (plus higher-level content inspection), why relying on a single layer is fragile, and the ephemeral-port detail that explains most NACL connectivity failures.

This section

What is true here

  1. A security group is stateful and allow-only, attached to the resource — return traffic for an allowed connection is automatic.
  2. A network ACL is stateless and can allow or deny, attached to the subnet — rules are evaluated in number order, first match wins.
  3. Default to a security group for most access rules; a network ACL adds a subnet-wide explicit deny when needed; WAF/inspection handles content-aware filtering.
  4. No single layer (placement, security group, or NACL) should be the only thing standing between a resource and unwanted access.
  5. A reply is addressed to the client's ephemeral port (1024–65535), not the service port — a NACL needs an explicit rule for that range, a security group does not.

What you will be able to do

  • Explain why a security group has no explicit deny rule and does not need a separate outbound rule for replies
  • Order network ACL rules correctly so a specific DENY takes precedence over a broader ALLOW
  • Choose between a security group, a network ACL, and content inspection for a given requirement
  • Design placement, security group, and NACL together so no single misconfiguration exposes a resource
  • Diagnose a one-way connectivity failure by checking a NACL's ephemeral-port outbound rule
Two filtering layers, stacked

Security group

  • +Stateful — return traffic automatic
  • +Allow-only, no explicit deny
  • +Attached to the resource (ENI)

Network ACL

  • Stateless — ephemeral ports need explicit rules
  • Allow AND deny, rule-number ordered
  • Attached to the subnet
  • Security group
    • Stateful — return traffic automatic
    • Allow-only, no explicit deny
    • Attached to the resource (ENI)
  • Network ACL
    • Stateless — ephemeral ports need explicit rules
    • Allow AND deny, rule-number ordered
    • Attached to the subnet

Security Groups and Network ACLs

What each layer actually does, when to reach for which one, why layering matters, and the ephemeral-port detail behind most NACL connectivity failures.

Security groups: stateful, instance-level firewalls

coreintermediate

A security group is a stateful firewall attached to a resource (an instance, a load balancer, an RDS database) — it can only allow traffic, never explicitly deny, and once inbound traffic is allowed, the matching return traffic is automatically allowed back out.

Think of it as

A guest list at a private event: the list only says who gets in (allow-only, no explicit deny needed for everyone else), and once someone is let in, they can freely leave without being checked again at the door.

bash
aws ec2 authorize-security-group-ingress --group-id sg-0abc123 \
  --protocol tcp --port 443 --source-group sg-0alb456

What we're doing: See a security group referencing another security group as its source, instead of a hardcoded IP range.

app-sg-ingress.jsonjson
{
  "IpPermissions": [
    { "IpProtocol": "tcp", "FromPort": 443, "ToPort": 443,
      "UserIdGroupPairs": [{ "GroupId": "sg-0alb456" }] }
  ]
}
3
The source is another security group (sg-0alb456, the load balancer's), not a CIDR block.
4
Any instance later added to sg-0alb456 automatically gets this access — nothing on the app side needs to change.

Why this works: Referencing a security group instead of a fixed IP range means the rule stays correct automatically as instances behind the load balancer scale up, down, or get replaced — a hardcoded IP range would need updating every time.

Trying to add an explicit "deny" rule to a security group

Wrong

text
# "I'll add a deny rule to block that one bad IP range from this security group."

Better

text
# Security groups are allow-only — use a network ACL for an explicit deny,
# or simply don't add an allow rule for that source

What you see: The console or CLI offers no way to add a "deny" entry — only "allow" — and confusion follows about how to block a specific range.

Why: A security group's rule set is a list of allows, evaluated as a whole — anything not explicitly allowed is implicitly denied, but there is no mechanism to explicitly deny something that would otherwise match an allow rule. That capability exists one layer up, at the network ACL.

Stateful in practice

Inbound rule added

  • +Allow TCP 443 from sg-alb (the load balancer's security group)
  • +A request from that source on port 443 is let through

No outbound rule needed for the reply

  • The response to that same connection is automatically allowed out
  • No matching outbound rule had to be added by hand
  • Inbound rule added
    • Allow TCP 443 from sg-alb (the load balancer's security group)
    • A request from that source on port 443 is let through
  • No outbound rule needed for the reply
    • The response to that same connection is automatically allowed out
    • No matching outbound rule had to be added by hand

Remember: Security groups are stateful and allow-only, attached to the resource itself — return traffic for an allowed connection is automatic, and there is no explicit deny.

See also: network acls as stateless controls · layered controls design

Network ACLs: stateless, subnet-level rules

coreintermediate

A network ACL filters traffic at the subnet boundary, evaluating numbered rules in order until one matches, and — unlike a security group — it is stateless: allowing inbound traffic does not automatically allow the response back out.

Think of it as

A building-wide policy list, checked top to bottom by rule number, where the first matching line wins — and, because it forgets everything between visits, someone let in the front door still needs a separate rule to be let back out.

text
Rules evaluated in rule-number order → first match wins → default deny-all as the last, unremovable rule

What we're doing: See how rule order changes the outcome for the exact same two rules.

rule-order-matters.txttext
# Order A — DENY evaluated first
Rule 50:  DENY  TCP 443 from 203.0.113.0/24
Rule 100: ALLOW TCP 443 from 0.0.0.0/0
# result: 203.0.113.5 is blocked, everyone else allowed

# Order B — ALLOW evaluated first
Rule 50:  ALLOW TCP 443 from 0.0.0.0/0
Rule 100: DENY  TCP 443 from 203.0.113.0/24
# result: 203.0.113.5 is allowed — rule 50 already matched, rule 100 never evaluated
2
A lower rule number is evaluated first — this DENY at 50 catches the specific range before the broader ALLOW at 100 is reached.
7
With numbers swapped, the broad ALLOW at 50 now matches first for everyone, including 203.0.113.5 — the DENY at 100 never gets a chance to apply.

Why this works: Unlike a security group (where all matching allow rules simply apply, order irrelevant), a network ACL stops at the first matching rule — the same two rules produce opposite results depending purely on their assigned numbers.

Adding a DENY rule with a higher number than an overlapping ALLOW rule

Wrong

text
# Rule 100: ALLOW all traffic from 0.0.0.0/0
# Rule 200: DENY traffic from a specific bad actor's IP

Better

text
# Rule 90:  DENY traffic from the specific bad actor's IP
# Rule 100: ALLOW all other traffic from 0.0.0.0/0

What you see: A DENY rule that should block a specific IP has no effect at all — traffic from that IP is still allowed through.

Why: Because a network ACL stops at the first matching rule, a broad ALLOW at a lower number matches before a more specific DENY at a higher number is ever reached — the specific rule needs the lower number to take precedence.

Same two rules, opposite outcome — only the numbers changed

DENY evaluated first (rule 50)

  • +Rule 50: DENY 203.0.113.0/24
  • +Rule 100: ALLOW 0.0.0.0/0
  • +Result: that range is blocked

ALLOW evaluated first (rule 50)

  • Rule 50: ALLOW 0.0.0.0/0
  • Rule 100: DENY 203.0.113.0/24 — never reached
  • Result: everyone is allowed
  • DENY evaluated first (rule 50)
    • Rule 50: DENY 203.0.113.0/24
    • Rule 100: ALLOW 0.0.0.0/0
    • Result: that range is blocked
  • ALLOW evaluated first (rule 50)
    • Rule 50: ALLOW 0.0.0.0/0
    • Rule 100: DENY 203.0.113.0/24 — never reached
    • Result: everyone is allowed

A network ACL rule set (evaluated in rule-number order)

A network ACL rule set (evaluated in rule-number order)
Rule #TypeProtocolPortSourceAllow/Deny
100InboundTCP4430.0.0.0/0ALLOW
200InboundTCPall203.0.113.0/24DENY
*Inboundallall0.0.0.0/0DENY (implicit last rule)

Together

bash
aws ec2 create-network-acl-entry --network-acl-id acl-0abc123 \
  --rule-number 200 --protocol -1 --egress false \
  --cidr-block 203.0.113.0/24 --rule-action deny

Remember: Network ACLs are stateless, subnet-level, and rule-number-ordered — the first matching rule wins, and inbound/outbound must each be explicitly allowed, including the ephemeral-port response.

See also: security groups as stateful firewalls · ephemeral ports and statefulness

Choosing security groups, NACLs, or inspection

standardintermediate

Security groups handle almost all day-to-day access control. Network ACLs add a coarser, explicit-deny subnet boundary on top when needed. Something like AWS WAF or a firewall inspects the actual content of traffic, not just its source and port.

Think of it as

Building security in layers: an ID check at each office door (security group), a guard at the building entrance who can turn specific people away outright (network ACL), and a metal detector that inspects what someone is actually carrying (deep inspection, WAF).

text
Most access rules       → security group
Subnet-wide explicit deny → network ACL
Content-aware filtering  → AWS WAF / inspection

What we're doing: Match three different real requirements to the layer that actually solves each one.

requirement-to-layer.txttext
# "The app tier should only accept traffic from the load balancer."
→ security group, source = load balancer's security group

# "Block this entire subnet from a known-bad IP range, no exceptions."
→ network ACL, explicit DENY rule

# "Block SQL-injection-looking payloads in incoming HTTP requests."
→ AWS WAF, a rule inspecting the request body/query string
2
This is source-and-port filtering — exactly what a security group is built for, and the simplest layer that solves it.
5
A network ACL's explicit DENY applies subnet-wide with no per-resource exceptions needed — a security group has no deny to express this with.
8
Neither a security group nor a NACL can look inside a request's payload — that needs a layer that actually inspects content.

Why this works: Each requirement names its own natural layer once framed as "what does this rule actually need to know" — source/port, a subnet-wide deny, or the request's content. Reaching for the wrong layer either cannot express the rule at all, or over-complicates a simple one.

Reaching for a network ACL to solve an instance-specific access rule

Wrong

text
# "Only this one instance should accept traffic on port 5432" — solved with a subnet-wide NACL rule

Better

text
# A NACL applies to every resource in the subnet — use a security group
# scoped to that specific instance/ENI instead

What you see: Every other resource sharing the subnet is unexpectedly affected by a rule that was only meant to apply to one instance.

Why: A network ACL has no concept of "this one resource" — its scope is the whole subnet. A rule meant for one instance belongs on that instance's security group, which is exactly the granularity a security group was designed for.

Remember: Default to a security group for most rules; add a network ACL for a subnet-wide explicit deny; add WAF or inspection only when the decision depends on request content, not just source and port.

See also: security groups as stateful firewalls · network acls as stateless controls · layered controls design

Designing layered network controls

standardintermediate

A single boundary that fails open leaves nothing behind it. Combining security groups (per-resource), network ACLs (per-subnet), and correct routing (public/private placement) means one misconfiguration does not expose everything by itself.

Think of it as

A safe inside a locked room inside a locked building — if any single lock is picked, the other two still hold, instead of the whole thing depending on one door.

text
Placement (private subnet) + Security group (per-resource) + NACL (per-subnet) = no single point of failure

What we're doing: See how the same misconfiguration behaves differently with one layer versus three.

single-vs-layered.txttext
# Single layer: security group only, in a public subnet
security group rule accidentally opens 0.0.0.0/0 on port 5432
→ database is immediately internet-reachable

# Layered: private subnet + security group + NACL
security group rule accidentally opens 0.0.0.0/0 on port 5432
→ subnet has no route to an internet gateway, so nothing external can reach it anyway
2
With only one layer, this single mistake is immediately and fully exploitable from the internet.
6
The identical mistake is contained — the private subnet's lack of a route to an internet gateway means there is no path in from outside, regardless of what the security group allows.

Why this works: The security group mistake is identical in both scenarios — what changes is whether any other layer was in a position to limit the damage. Layered design assumes any single layer will eventually be misconfigured, and asks what still holds when it is.

Treating a database's security group as the only thing keeping it safe

Wrong

text
# Database in a public subnet, protected only by a tightly scoped security group

Better

text
# Database in a private subnet (no internet gateway route) AND a tightly
# scoped security group — either mistake alone is contained

What you see: A single accidental security group change (a broad CIDR, a wrong port) is immediately and fully exploitable, with nothing else standing in the way.

Why: Relying on exactly one control point means that control point's correctness is the entire security posture — subnet placement (public vs private) is close to free to get right and provides an independent layer that a security group mistake alone cannot bypass.

Remember: No single layer — not subnet placement, not a security group, not a NACL — should be the only thing standing between a resource and unwanted access; each layer should catch a different class of mistake.

See also: choosing the right control · public vs private subnets

Ephemeral ports and why statefulness matters

coreintermediate

A client's reply to a server does not come back on the port it connected to — it comes back to a random high "ephemeral" port the client chose for that connection, which is exactly the traffic a stateless NACL needs its own explicit outbound rule for.

Think of it as

Calling a company's main phone line, then having them call back to your personal cell number to continue the conversation — the return call uses a different number entirely, chosen at the moment the first call was placed.

text
Client:1024-65535 → Server:443   (request)
Server:443 → Client:1024-65535   (response, addressed to the ephemeral port)

What we're doing: See the NACL rules a server subnet actually needs to both accept requests and let its replies leave.

server-subnet-nacl.txttext
Inbound:
  100  ALLOW  TCP 443       from 0.0.0.0/0     # the actual request

Outbound:
  100  ALLOW  TCP 1024-65535 to 0.0.0.0/0      # the reply, to the client's ephemeral port
2
This rule alone lets requests reach the server on port 443.
5
Without this rule, the server can receive requests but its replies have nowhere to go — the connection hangs from the client's side.

Why this works: A NACL has no memory of the inbound request when evaluating the outbound reply — the ephemeral port range has to be explicitly allowed outbound, or the request appears to succeed at the server while the client never sees a response.

Diagnosing a one-way connectivity failure as a security group problem first

Wrong

text
# "Requests aren't getting responses — must be the security group blocking something."

Better

text
# Check the subnet's NACL outbound rules for the ephemeral port range
# first — security groups already handle return traffic statefully

What you see: Time is spent re-checking an already-correct security group, because the actual gap is a NACL missing its ephemeral-port outbound rule.

Why: Security groups never need a separate rule for return traffic — they are stateful by design. When return traffic specifically is missing, a NACL's stateless outbound rules are the more likely place to look, not the security group.

Where the ephemeral port shows up
Client
Server
  1. 1. SYN from port 51422 → 443
  2. 2. reply to port 51422not to port 443 on the client
  1. Client → Server: SYN from port 51422 → 443
  2. Server → Client: reply to port 51422 (not to port 443 on the client)

Remember: A reply is addressed to the client's ephemeral port (1024–65535), not the service port the request arrived on — a stateless NACL needs an explicit rule for that range, which a stateful security group never does.

See also: network acls as stateless controls · security groups as stateful firewalls

Advertisement