VPC segmentation

Identity-referenced SGs, private subnets, blast radius.

Advanced30 min · lesson 10 of 15

A leaked password gets an attacker onto one machine. Whether that becomes one bad afternoon or a company-ending breach depends almost entirely on what that one machine can reach next. Identity decides who you are. The network decides where you can go once you're inside, and a good layout means a stolen credential drops the attacker into a small locked room instead of the whole building.

A well-run office building handles this with zones. The lobby is open to the street. The office floors need a badge to enter. The vault in the basement takes a second badge that almost nobody carries, and its door only opens for people already inside the building, never for someone walking in off the pavement. AWS lets you build the same zoning inside a VPC (a virtual private cloud, your own private slice of the AWS network). The whole job here is setting those zones up so they stay closed until you deliberately open one narrow path.

The doorman who remembers you

A security group is a small firewall wrapped around a single network card. Every server or load balancer in AWS sits behind a virtual network interface (an ENI, or elastic network interface), and its security group is the list of who may talk to that interface. The word that matters is stateful. Once you allow a connection in, the reply is allowed back out automatically, the way a doorman who watched you walk in won't stop you on the way out. You write the rule for the direction you care about and the return path takes care of itself.

Here's the part teams get wrong. You can write a rule that allows a range of addresses (a CIDR block, the 10.0.1.0/24 style notation for a group of IPs), but on a live network those addresses shift constantly as servers scale up and down. So don't allow addresses. Allow a badge. A rule that says accept database connections from anything wearing the app-server badge keeps working no matter how many app servers come and go, and it never accidentally admits a machine that happened to inherit an old IP. In AWS terms, the database's security group allows the app's security group by ID.

allow Postgres from the app-tier badge, not an IP
aws ec2 authorize-security-group-ingress \
--group-id sg-0db1a2c3d4e5f6a7b \
--ip-permissions 'IpProtocol=tcp,FromPort=5432,ToPort=5432,UserIdGroupPairs=[{GroupId=sg-0a9b8c7d6e5f4a3b2,Description="postgres from app tier only"}]'
{
"Return": true,
"SecurityGroupRules": [
{
"SecurityGroupRuleId": "sgr-041c2e3f4a5b6c7d8",
"GroupId": "sg-0db1a2c3d4e5f6a7b",
"IsEgress": false,
"IpProtocol": "tcp",
"FromPort": 5432,
"ToPort": 5432,
"ReferencedGroupInfo": {
"GroupId": "sg-0a9b8c7d6e5f4a3b2"
},
"Description": "postgres from app tier only"
}
]
}

Read the rule back and the distinction is visible at a glance: the inbound source is a group, and the list of allowed IP ranges is empty. There is no address path into this database, only a badge.

read it back: the source is a group, not a CIDR
aws ec2 describe-security-groups \
--group-ids sg-0db1a2c3d4e5f6a7b \
--query 'SecurityGroups[0].IpPermissions'
[
{
"IpProtocol": "tcp",
"FromPort": 5432,
"ToPort": 5432,
"IpRanges": [],
"Ipv6Ranges": [],
"PrefixListIds": [],
"UserIdGroupPairs": [
{
"GroupId": "sg-0a9b8c7d6e5f4a3b2",
"UserId": "123456789012",
"Description": "postgres from app tier only"
}
]
}
]

The bouncer with no memory

A network ACL, or NACL (network access control list), does a related job one level up, guarding the edge of an entire subnet instead of a single interface. The catch that trips almost everyone is that a NACL is stateless. It has no memory. It judges every packet going in and every packet coming out as separate decisions, so allowing a request in does not allow the answer back out. You have to write both directions yourself. Replies come back on high-numbered temporary ports (ephemeral ports, which AWS suggests covering as the whole 1024 to 65535 range), so a NACL that allows only port 5432 inbound will accept the database query and then quietly drop the response. The connection just hangs.

Because of that, don't reach for NACLs to express fine-grained rules. Keep security groups for per-workload allow lists, and use NACLs as a blunt, subnet-wide backstop, the sort of thing that says no traffic from this banned range touches this subnet at all, whatever the security groups permit. NACL rules are also numbered and evaluated lowest first, stopping at the first match, so ordering matters in a way security group rules (a pure set of allows) never do. And a NACL can express an explicit deny, which a security group cannot, so it's the right tool when you need a hard block rather than a simple allow.

the stateless NACL needs the return trip spelled out
aws ec2 describe-network-acls \
--network-acl-id acl-08f5e4d3c2b1a0f9e \
--query 'NetworkAcls[0].Entries[?RuleNumber!=`32767`]'
[
{
"RuleNumber": 100,
"Protocol": "6",
"RuleAction": "allow",
"Egress": false,
"CidrBlock": "10.20.10.0/24",
"PortRange": { "From": 5432, "To": 5432 }
},
{
"RuleNumber": 120,
"Protocol": "6",
"RuleAction": "allow",
"Egress": true,
"CidrBlock": "10.20.0.0/16",
"PortRange": { "From": 1024, "To": 65535 }
}
]

Protocol 6 is TCP. Rule 100 lets the database query in on 5432. Rule 120 is the one people forget: it lets the reply back out on the ephemeral port range. Leave rule 120 out and every allowed connection stalls, even though the inbound rule looks perfect. The default catch-all rule (number 32767, a deny) is filtered out of the view above so the two real entries stand on their own.

Three keycard zones

The rules only contain a breach if the layout underneath them is right. The standard shape is three tiers, each in its own subnet. Public subnets hold only what must face the internet: load balancers, and the NAT gateway (network address translation, a one-way outbound door) that lets private servers fetch updates without being reachable themselves. Private subnets hold your application servers, with no route to the internet gateway at all, so nothing outside can dial them directly. Data subnets hold databases and caches, and get neither an internet gateway nor a NAT route, so a database can't be reached from outside and can't quietly phone out either. Each subnet is a keycard zone, and the badge rules from earlier decide which zone may knock on the next.

This is what bounds the damage. An attacker who lands on a public load balancer finds it can reach the app tier on exactly one port and nothing else. From the app tier, only the database port into the data tier. The vault never sees the street. For real separation between environments, give each one its own AWS account so production and staging share no network by default. Nothing routes between two separate accounts unless you deliberately build a path between them, so a slip in staging can't leak into production. That gap is a wall, not a badge door someone might forget to lock.

One thing this design deliberately can't do: it governs who may start a connection to your servers, not where your servers may reach out. Security groups and NACLs don't inspect the contents of traffic or filter by domain name, so a compromised app server can still open an outbound connection to anywhere it likes. Closing that path is egress control, and it takes a different tool.

Default-closed VPC: three keycard zones
public tier (faces the street)
Application Load Balancer
allows 443 from 0.0.0.0/0, the only public door
NAT gateway
one-way outbound path for private servers
private tier (badge only)
app servers
allow the load-balancer SG on :443, nothing else
no internet gateway route
cannot be dialed from outside
data tier (the vault)
PostgreSQL / RDS
allow the app SG on :5432, no public path
ElastiCache
allow the app SG on :6379, no NAT route out
Every arrow between tiers is a security group referencing the tier above it by ID, never a CIDR. A foothold in one zone can knock on exactly one door, not roam the building.
New security groups allow all outbound by default
Every security group you create ships with one egress rule already in place: allow all traffic to 0.0.0.0/0. Teams tighten inbound rules with great care and never look at outbound, so a compromised server can still open a connection to anywhere on the internet. If you want real containment, delete that default egress rule and add back only the specific destinations the workload actually needs. The discipline you applied to inbound has to apply going out too.
Quick check
01You allow inbound TCP 5432 on a subnet's network ACL so the app tier can reach the database, but connections hang and time out. The security groups are correct. What's missing?
Correct — A NACL is stateless, so the return traffic on high-numbered ephemeral ports needs its own explicit allow. Without it the query goes in and the answer is dropped.
Incorrect — The reply doesn't come back on 5432. It returns on a temporary high-numbered port, so another 5432 rule changes nothing.
Incorrect — The question says the security groups are fine, and a CIDR reference would be worse practice, not a fix.
Incorrect — The data tier should never have an internet gateway route. That would expose the database, not fix the return traffic.
02The lesson tells you to write the database's security group to allow the application tier's security group by ID rather than allowing the app servers' IP range (a CIDR block, the 10.0.1.0/24-style notation for a group of addresses). On a live autoscaling network, why is referencing the group ID the safer choice?
Incorrect — CIDR-based rules can specify ports perfectly well; the port is not the reason to prefer a group reference.
Incorrect — a security group is stateful regardless of whether its source is a group or a CIDR, so statefulness is not tied to the source type.
Correct — the lesson's point is that live addresses shift, so allowing the 'badge' (the group) stays correct while an address rule can go stale or admit the wrong host.
Incorrect — security group rules are a pure set of allows with no ordering or priority between them.
03Your team carefully tightened every inbound security group rule in the VPC, yet a compromised app server still opens an outbound connection to an attacker's server on the internet and exfiltrates data. Based on the lesson, what was overlooked?
Incorrect — security groups evaluate inbound and outbound separately, so tight inbound rules say nothing about what the host may reach outward.
Correct — the lesson warns that new groups allow all outbound by default and that the inbound discipline has to be applied going out too.
Incorrect — a blocked return path would stall legitimate replies, not enable an outbound connection to the attacker.
Incorrect — security groups and NACLs don't inspect content or filter by domain; that is egress control, a different tool the next lesson covers.

Try this

Work through “Three keycard zones” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: new security groups allow all outbound by default. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related