·8 min read

AWS EC2 Security Groups: The Inbound/Outbound Rules That Will Trip You Up

Stateful vs stateless, implicit denies, ephemeral ports, IPv6 gaps, the real gotchas in EC2 security group rules that caused me actual production pain.

AWSEC2Security GroupsNetworkingCloud

Every AWS beginner hits the same wall: you follow the tutorial, open port 80 inbound, and the thing works. Then you go slightly off-script and spend two hours debugging a connection that should work but doesn't. This post is everything I wish I'd known before I wasted those hours.

The Core Misunderstanding: Stateful vs Stateless

Security Groups (SGs) are stateful. Network ACLs (NACLs) are stateless. This single distinction explains about 80% of the confusion.

Stateful means: if you allow traffic in one direction, the return traffic is automatically allowed. You open port 443 inbound? The response packets back to the client go out, no outbound rule needed.

Stateless means: every packet is evaluated independently in both directions. No memory of the connection. You open port 443 inbound on a NACL? You also need an outbound rule for the ephemeral port range, or the response never leaves.

Think of Security Groups as a bouncer who remembers faces, if they let you in, they'll let you out. NACLs are a metal detector: every time, both ways, no exceptions.

Most tutorials only use Security Groups, so you never feel the pain. Until you add a NACL and suddenly nothing works.

Why "Allow All Outbound" Is Not a Security Blanket

The AWS default, and the template every tutorial uses, opens all outbound traffic:

Protocol: All
Port range: All
Destination: 0.0.0.0/0

This feels like security because you're thinking about it as "requests my server makes." But an attacker who gets code execution on your EC2 instance can:

  • Exfiltrate data via HTTP/S to any destination
  • Use your instance as a bot in a DDoS attack
  • Call out to a C2 (command-and-control) server over any port

Opening all outbound defeats a significant layer of defence-in-depth. The better approach for a web server is:

Outbound rule 1: TCP 443 → 0.0.0.0/0   (HTTPS calls to external APIs)
Outbound rule 2: TCP 5432 → sg-xxxxxx   (Postgres in the same VPC, referenced by SG ID)
Outbound rule 3: UDP 53 → 0.0.0.0/0    (DNS)

Lock it down to what your app actually needs. Anything else is a hole you're leaving open by default.

The Implicit Deny, Nothing Is "Allowed by Default"

Security Groups have no concept of "allow unless denied." Every rule is an allow rule. Anything without a matching allow rule is denied silently, no ICMP "connection refused," no error message, just a timeout.

This is a debugging nightmare because:

  1. You open port 80. Works.
  2. You add a rule for port 8080. Works.
  3. You try port 3000. Nothing. Timeout. You assume your app isn't running.

Your app is fine. Port 3000 just isn't in the security group. The implicit deny is invisible.

Timeouts when you expect an immediate error are almost always a Security Group or NACL deny. A true "port closed" returns an RST packet instantly. A hanging connection is being silently dropped.

Source/Destination: CIDR vs Security Group ID

You can specify traffic sources as:

  • A CIDR block (0.0.0.0/0, 10.0.0.0/16, your office IP, etc.)
  • Another Security Group ID (sg-0abc123...)

The Security Group ID option is almost always the right answer inside a VPC. Here's why:

# Fragile: CIDR-based (breaks if you add instances or change IPs)
Inbound: TCP 5432 from 10.0.1.0/24

# Robust: SG-based (automatically includes any instance in that SG)
Inbound: TCP 5432 from sg-app-servers

When you reference an SG as a source, AWS dynamically resolves to "any ENI currently associated with that security group." You can scale your app fleet from 1 instance to 100 and the database rule never needs updating.

Using CIDRs inside a VPC is technical debt, you'll hit it when you least expect it, usually during an incident at 2am.

The Ephemeral Port Problem (NACL-Specific)

Back to NACLs. Even if you understand stateless, the ephemeral port range bites you.

When a client connects to your server on port 443, the client picks a random ephemeral port (1024–65535) for the response to come back to. Your NACL needs an outbound rule that covers that range:

Outbound: TCP 1024-65535 → 0.0.0.0/0

Linux kernels typically use 32768–60999. The IANA standard range is 49152–65535. AWS documentation recommends allowing 1024–65535 to be safe.

If you forget this, your inbound traffic reaches the instance, your app responds, but the TCP handshake can't complete because the response is blocked by the NACL on the way out.

Here's a quick reference for what you need at the NACL level for a typical public web server:

DirectionProtocolPort RangePurpose
InboundTCP80HTTP
InboundTCP443HTTPS
InboundTCP1024–65535Return traffic for outbound connections
OutboundTCP80, 443HTTP/S for software updates, API calls
OutboundTCP1024–65535Return traffic for inbound connections
OutboundUDP53DNS
InboundUDP1024–65535DNS response return

Security Groups don't need any of that, they handle it automatically.

IPv6: You Need Separate Rules

This one gets people who think they've covered everything.

If your VPC has IPv6 enabled (::/0) and your EC2 instance has an IPv6 address, clients on IPv6 networks will try to connect via IPv6. Your IPv4 rule (0.0.0.0/0) does not cover IPv6 traffic.

You need both:

# HTTP
Inbound: TCP 80 from 0.0.0.0/0
Inbound: TCP 80 from ::/0          ← easy to forget

# HTTPS
Inbound: TCP 443 from 0.0.0.0/0
Inbound: TCP 443 from ::/0         ← easy to forget

The symptom: works fine from most users, mysteriously fails for users on modern ISPs that default to IPv6 (T-Mobile home internet, many European carriers, anyone on IPv6-only mobile data).

If you're using an ALB in front of EC2, the ALB handles IPv6 termination and forwards as IPv4 to your instances. In that architecture, you only need IPv4 rules on the instance SG, but the ALB SG still needs both.

The "0 References" Trap

Security Groups can exist with zero instances attached. AWS doesn't warn you when you're editing the wrong one.

Classic scenario:

  1. You create sg-web-prod and attach it to your instances.
  2. Two months later you create a new instance and AWS auto-creates sg-launch-wizard-1.
  3. You're editing rules in the console. You click on the wrong SG. You add your rule. Nothing changes for your running instances.

Always verify you're editing the right SG by checking the "Associated resources" tab before you start, and check the "Inbound rules" tab on the instance Network tab, not just the SG console.

The Rule That Fixed My Production Outage

True story: had a Lambda → RDS Proxy → Aurora setup where Lambda was timing out. Lambda was in a VPC. The SG on the RDS Proxy was correctly allowing TCP 5432 from the Lambda SG. Everything looked right.

The fix: Lambda's SG needed an outbound rule allowing TCP 5432 to the RDS Proxy SG.

SGs are stateful for traffic passing through the instance, but for ENI-to-ENI traffic inside a VPC, both ends need rules. The Lambda ENI needed outbound 5432 → rds-proxy-sg, and the RDS Proxy ENI needed inbound 5432 ← lambda-sg. I only had the second half.

For VPC-internal communication between managed services (Lambda, RDS, ECS tasks), always check both ends: outbound on the caller and inbound on the callee.

Checklist Before You File an AWS Support Ticket

  • Confirmed you're editing the SG actually attached to the instance (check instance → Networking tab)
  • If using NACLs: added both inbound and outbound rules including ephemeral port ranges
  • If IPv6 is enabled: added ::/0 rules alongside 0.0.0.0/0
  • For managed services: checked outbound rules on the caller's SG, not just inbound on the callee
  • If using SG references: confirmed the instances are actually in the referenced SG
  • Tested with nc -zv <host> <port> or telnet from within the VPC to isolate SG vs app issue

Most networking issues in AWS are rule omissions, not misconfigurations. When in doubt: check the other end of the connection.