Egress control & Network Firewall
Domain allowlists, DNS Firewall, exfiltration defense.
Most teams lock the front door and leave the back door propped open. Inbound traffic gets firewalls, security groups, and network ACLs, all carefully tuned. Outbound usually gets one line: allow everything to 0.0.0.0/0. That open back door is exactly what an attacker needs. A compromised workload has to do two things over the network, call home for instructions and push your data out, and both of those are outbound connections. If nothing inspects what leaves, a break-in stays completely silent.
A mailroom that checks every outgoing address
A well-run mailroom doesn't let staff toss packages straight into the outbound bin. Every parcel gets its destination checked against a list of approved recipients before it leaves the building, and anything addressed somewhere odd gets held and flagged. AWS Network Firewall is that mailroom for your VPC. A VPC (virtual private cloud) is your own walled-off slice of the AWS network, and the firewall is a managed appliance you place inside it that reads traffic from the raw IP layer all the way up to the application layer. That means it can see the domain name a connection is trying to reach, which security groups and network ACLs cannot. Those filter on IP address and port only, and since real services hide behind rotating IPs and shared CDNs, an IP allowlist is a game you lose.
The rules are written in Suricata, an open intrusion-detection language the security industry already speaks. Each rule is a single line: an action (pass, drop, or alert), a protocol, a direction, and the conditions to match. The condition that makes egress control work is tls.sni. When a workload opens an HTTPS connection, the very first thing it sends, before any encryption kicks in, is the name of the server it wants to reach. That name is the Server Name Indication, or SNI, and the firewall reads it in plain sight. So you write a few pass rules for the domains you genuinely use, then one drop rule that catches every other TLS connection and logs it by name. The firewall policy holding these rules is set to read them strictly, in order, with a final answer of drop for anything left unmatched. That default is what stops the sneaky stuff. A raw non-TLS connection straight to a bare IP never matches a tls rule, so it falls through to the drop and dies there.
pass tls $HOME_NET any -> $EXTERNAL_NET any (tls.sni; dotprefix; content:".amazonaws.com"; endswith; msg:"allow AWS APIs"; sid:1; rev:1;)pass tls $HOME_NET any -> $EXTERNAL_NET any (tls.sni; content:"registry.npmjs.org"; endswith; msg:"allow npm registry"; sid:2; rev:1;)drop tls $HOME_NET any -> $EXTERNAL_NET any (msg:"blocked egress: unapproved FQDN"; sid:9999; rev:1;)
aws network-firewall create-rule-group \--rule-group-name egress-allowlist \--type STATEFUL --capacity 100 \--rules file://egress.rules \--region eu-west-1{"UpdateToken": "b1f3c8e2-7a44-4d90-9c11-0e2f5a6b7c88","RuleGroupResponse": {"RuleGroupArn": "arn:aws:network-firewall:eu-west-1:222222222222:stateful-rulegroup/egress-allowlist","RuleGroupName": "egress-allowlist","RuleGroupId": "9c2b7d1e-8a34-4f7a-b0c1-6d2e5f9a1b34","Type": "STATEFUL","Capacity": 100,"ConsumedCapacity": 3,"RuleGroupStatus": "ACTIVE","NumberOfAssociations": 0}}
Force every packet through the mailroom
A rule group does nothing until traffic physically passes through the firewall, and this is the step people skip. Network Firewall lives in its own dedicated subnet, one that hosts nothing else, and it exposes a VPC endpoint, a gateway you can aim routes at. To make a workload subnet use it, you rewrite that subnet's route table so the default route (0.0.0.0/0) no longer points straight at the NAT gateway. It points at the firewall endpoint instead. The firewall inspects, applies your rules, and only then hands approved traffic onward to NAT and out to the internet. The workload can't route around it, because the route table is the only map it has.
aws network-firewall describe-firewall --firewall-name egress-fw \--query 'FirewallStatus.SyncStates."eu-west-1a".Attachment' \--region eu-west-1{"SubnetId": "subnet-0f1a2b3c4d5e6a7b8","EndpointId": "vpce-0a1b2c3d4e5f6a7b8","Status": "READY"}aws ec2 create-route --route-table-id rtb-0a1b2c3d4e5f6a7b8 \--destination-cidr-block 0.0.0.0/0 \--vpc-endpoint-id vpce-0a1b2c3d4e5f6a7b8 \--region eu-west-1{"Return": true}
One detail bites everyone at least once. The firewall subnet needs its own route onward to the NAT gateway, and the NAT subnet needs a route back to the firewall endpoint for the workload's CIDR range. If that return path is wrong, traffic the firewall happily approved still can't complete the round trip, so healthy apps break while you stare at a pass rule that looks perfect. Sketch the path in both directions before you touch a production route table.
In a single VPC this stays simple. Across an organisation you don't want a separate firewall in all fifty VPCs, each billing an hourly endpoint fee. The usual pattern is one central inspection VPC in a shared networking account, with a Transit Gateway steering every spoke VPC's egress through a single firewall fleet. You pay per firewall-hour plus per gigabyte inspected, so centralising cuts the bill and gives you one allowlist to edit. The catch is blast radius: one bad rule now touches every account, so the rule group belongs in version control with review, never hand-typed in the console.
Watch the blocks land
Blocking without logging is only half a control. Every time a drop or alert rule fires, Network Firewall writes a record to wherever you sent its logs, whether that's CloudWatch Logs, an S3 bucket, or a Kinesis Data Firehose stream. Each record is JSON in the Suricata EVE format, and the fields that matter are the SNI the workload tried to reach, the rule that caught it, and the action taken. A compromised container beaconing to a command-and-control server shows up right here, as a steady trickle of blocked connections to a name nobody on your team ever approved. Without the log you'd see none of it.
aws logs filter-log-events \--log-group-name /aws/network-firewall/alert \--filter-pattern '{ $.event_type = "alert" && $.alert.action = "blocked" }' \--max-items 1 --region eu-west-1{"events": [{"logStreamName": "egress-fw/firewall/alert","timestamp": 1737033512114,"message": "{\"event_type\":\"alert\",\"src_ip\":\"10.0.12.37\",\"dest_ip\":\"203.0.113.44\",\"dest_port\":443,\"proto\":\"TCP\",\"tls\":{\"sni\":\"c2.malware-update.ru\",\"version\":\"TLS 1.3\"},\"alert\":{\"action\":\"blocked\",\"signature\":\"blocked egress: unapproved FQDN\",\"signature_id\":9999}}"}],"searchedLogStreams": [{ "logStreamName": "egress-fw/firewall/alert", "searchedCompletely": true }]}
That one log line is the whole difference between a breach you find months later during an audit and an alert that pages someone the same afternoon. Wire the alert log group to a metric filter and a CloudWatch alarm, or forward the records to whatever monitoring system your security team already watches, so a spike in blocked egress becomes a page instead of landfill. The firewall governs the traffic that genuinely has to leave your VPC. How much of it never needs to leave at all is the next question, and that's what private endpoints answer.
Try this
Work through “Watch the blocks land” 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: an SNI allowlist reduces the attack surface, it doesn't seal it. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.