Egress control, DNS & firewalls
NAT, egress proxies, DNS filtering, and exfiltration defense.
At an airport, arrivals barely get a glance. Departures get the real screening: the scanners, the watchlists, the agent who wants to know why one bag is full of hard drives. Most cloud networks run it the other way around. Teams spend their budget on the way in, on security groups, web application firewalls (WAFs), load balancers, and TLS (transport layer security) termination, and leave the way out completely unstaffed. Egress control is departures screening for your network. It decides what is allowed to leave, and it writes down every attempt.
Egress is any traffic leaving your VPC (virtual private cloud on AWS), your VNet (virtual network on Azure), or your subnet, bound for the internet or another network. The two things an attacker wants most both ride out on it. Exfiltration is your data leaving for somewhere it should not go. Command-and-control (C2) is a compromised machine phoning home to an attacker's server for its next set of orders. Both happen near the end of an attack, and both need one thing: an open path out. A default-open outbound policy assumes everything inside the fence can be trusted, the exact assumption a breach has already broken. Flip it. Pin egress to the short, stable list of places a workload truly needs, a package mirror, an internal API (application programming interface), a payment gateway, and deny the rest. For most workloads that list is a handful of names, so an allowlist is practical, and any reach toward an unlisted host turns from a silent success into a loud alert.
NAT moves the mail, it doesn't read it
A large office sends all its outgoing post through one mailroom, which stamps the company's street address on every envelope. The outside world sees one building, not five thousand desks. That is Network Address Translation, or NAT: many private machines share a single public IP (internet protocol) address on the way out. AWS calls its box a NAT Gateway, Google calls it Cloud NAT, Azure calls it a NAT Gateway as well. All three do the same job. They hand private workloads a route to the internet and one stable public address to leave from.
Here is the trap that catches a lot of teams. The mailroom stamps the envelope. It does not open it, read it, or check the recipient against any list. A NAT gateway is the same. It translates addresses and does nothing else. So 'we have a NAT gateway' and 'we control what leaves' are two different claims, and plenty of production accounts quietly treat the first as if it were the second. The NAT gives every compromised pod (a running container) the same clean exit your real traffic uses. To control egress you need two more things in the path: a default-deny rule that shuts the door, and a filter that decides which envelopes may pass.
You can watch the 'shared exit, no inspection' behavior in a single line. From a workload that has no public address of its own, ask the internet which address it sees you as. The reply is the NAT's public IP, shared by everything in that subnet. Notice what did not happen: nothing checked where the request was going.
# from a workload with no public IP of its own, sitting behind the NAT gatewaycurl -s https://checkip.amazonaws.com
203.0.113.47
Close the door: default-deny at layers 3 and 4
Layers 3 and 4 are the network's street-address layer. Layer 3 (internet protocol) carries IP addresses, and layer 4 (transport: TCP, the transmission control protocol, or UDP, the user datagram protocol) carries port numbers. A layer-3/4 rule is a doorman who knows addresses and door numbers and nothing else. Traffic to 10.20.0.0/24 on port 3128, fine. Anything else, no. Every cloud gives you this. AWS has security groups (SGs) and network ACLs (access control lists), Google has VPC firewall rules, Azure has network security groups (NSGs). The model is identical: set the default to deny outbound, then cut narrow holes for the destinations you trust.
AWS hands you a fresh security group with one outbound rule already in place: allow everything to 0.0.0.0/0, the whole internet. That default is the open door. Strip it, then add back only the route to your egress proxy on port 3128, the classic proxy port.
# strip the default allow-all egress rule, then permit only the proxy subnetaws ec2 revoke-security-group-egress --group-id sg-0a1b2c3d \--ip-permissions IpProtocol=-1,IpRanges='[{CidrIp=0.0.0.0/0}]'aws ec2 authorize-security-group-egress --group-id sg-0a1b2c3d \--ip-permissions 'IpProtocol=tcp,FromPort=3128,ToPort=3128,IpRanges=[{CidrIp=10.20.0.0/24,Description=egress-proxy}]'
{"Return": true}{"Return": true,"SecurityGroupRules": [{"SecurityGroupRuleId": "sgr-0a1b2c3d4e5f6a7b8","GroupId": "sg-0a1b2c3d","IsEgress": true,"IpProtocol": "tcp","FromPort": 3128,"ToPort": 3128,"CidrIpv4": "10.20.0.0/24","Description": "egress-proxy"}]}
# a low-priority EGRESS deny that catches everything an allow rule didn'tgcloud compute firewall-rules create deny-all-egress --network=prod-vpc \--direction=EGRESS --action=DENY --rules=all \--destination-ranges=0.0.0.0/0 --priority=65534
Creating firewall...done.NAME NETWORK DIRECTION PRIORITY ALLOW DENY DISABLEDdeny-all-egress prod-vpc EGRESS 65534 all False
# deny outbound to the Internet service tag from the app subnet's NSGaz network nsg rule create -g prod-rg --nsg-name app-nsg -n deny-internet-out \--priority 4096 --direction Outbound --access Deny --protocol '*' \--destination-address-prefixes Internet --destination-port-ranges '*'
{"access": "Deny","destinationAddressPrefix": "Internet","destinationAddressPrefixes": [],"destinationPortRange": "*","direction": "Outbound","name": "deny-internet-out","priority": 4096,"protocol": "*","provisioningState": "Succeeded","sourceAddressPrefix": "*","sourcePortRange": "*"}
Each cloud breaks ties by priority, and the numbers run backwards from what you might expect: lower number, higher priority. Google's network already holds an invisible allow-all-egress rule at priority 65535, so your deny at 65534 beats it by one. Azure keeps a default AllowInternetOutBound rule at priority 65001 (the Internet service tag is Azure's shorthand for every public address). Your own rules can only sit between 100 and 4096, so even 4096, the weakest slot Azure lets a user rule take, still lands far ahead of 65001 and wins. AWS has no implied allow once you strip the default rule, so an empty egress list denies on its own.
This buys you a real door, but a dumb one. Layer-3/4 rules speak only in IP addresses, and the destinations you care about hide behind moving ones. A package registry or a Git host sits behind a content delivery network (CDN) whose addresses change constantly and are shared by millions of unrelated customers. Pin the registry by address and your rule breaks next week. Allow the CDN's whole range and you have quietly authorized every other tenant on it. To filter by name instead of by number, you climb to layer 7.
Filter by name: the layer-7 egress firewall
Layer 7 is the application layer, where the real hostname lives. A layer-7 egress firewall is the border guard who reads the label on the parcel, not only the road it is headed down. The workload gets no direct route out. Every outbound flow is forced through one inspection point: Network Firewall in a dedicated egress VPC on AWS, Azure Firewall in a hub VNet on Azure, Secure Web Proxy on Google. The device reads the destination hostname and checks it against your allowlist. A name on the list passes. Everything else is dropped and logged.
Where does it read the hostname? In an HTTPS connection (the web's HTTP requests wrapped in TLS encryption) the client announces the server name in the clear, in a field called SNI (server name indication), before encryption starts. Matching on SNI is cheap and needs no certificates, but it trusts the name the client wrote and cannot see the URL path. To filter on the path you have to open the TLS session: the firewall terminates the connection with its own certificate, reads the plaintext, then re-encrypts. That buys path-level control and costs you a certificate pushed to every workload, plus latency and a privacy trade. Sensible default: SNI matching for the bulk of package and API traffic, full interception saved for the few genuinely high-risk paths.
The three products spell the same allowlist three ways. AWS Network Firewall is a stateful (connection-tracking) rule group built on Suricata, an open-source inspection engine; a domain allowlist compiles into rules that match the SNI and the HTTP Host header. Azure Firewall runs application rules that proxy HTTP and HTTPS and match on target FQDNs (fully qualified domain names, the complete dotted hostname). Secure Web Proxy is an explicit forward proxy (a server your clients hand their requests to, which then makes each request on their behalf) that runs a CEL (common expression language) matcher against a URL list. Different knobs, one question: is this hostname on the list, and if not, drop and log.
# a STATEFUL rule group that allows ONLY these domains (SNI + Host), drops the restaws network-firewall create-rule-group \--rule-group-name egress-fqdn-allowlist --type STATEFUL --capacity 100 \--rule-group '{"RulesSource":{"RulesSourceList":{"Targets":[".github.com",".pkg.dev","registry.internal"],"TargetTypes":["TLS_SNI","HTTP_HOST"],"GeneratedRulesType":"ALLOWLIST"}}}'
{"UpdateToken": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d","RuleGroupResponse": {"RuleGroupArn": "arn:aws:network-firewall:eu-west-1:123456789012:stateful-rulegroup/egress-fqdn-allowlist","RuleGroupName": "egress-fqdn-allowlist","RuleGroupId": "12345678-90ab-cdef-1234-567890abcdef","Type": "STATEFUL","Capacity": 100,"RuleGroupStatus": "ACTIVE"}}
One catch with the AWS version. The allowlist rule group only says which domains to pass. For 'deny the rest' to actually hold, the firewall policy it attaches to has to set its stateful default action to drop, otherwise unmatched traffic sails straight through. The allowlist and the default-drop are two separate settings, and forgetting the second is a popular way to ship a firewall that filters nothing.
# an application rule collection that allows these FQDNs over HTTPSaz network firewall policy rule-collection-group collection add-filter-collection \-g hub-rg --policy-name hub-fwpolicy --rule-collection-group-name egress-rcg \--name approved-fqdns --action Allow --collection-priority 200 \--rule-name pkg-and-api --rule-type ApplicationRule \--protocols Https=443 --source-addresses 10.30.0.0/16 \--target-fqdns '*.github.com' '*.pkg.dev' 'api.internal.contoso.com'
{"name": "egress-rcg","priority": 100,"provisioningState": "Succeeded","ruleCollections": [{"action": { "type": "Allow" },"name": "approved-fqdns","priority": 200,"ruleCollectionType": "FirewallPolicyFilterRuleCollection","rules": [{"name": "pkg-and-api","ruleType": "ApplicationRule","protocols": [ { "port": 443, "protocolType": "Https" } ],"sourceAddresses": [ "10.30.0.0/16" ],"targetFqdns": [ "*.github.com", "*.pkg.dev", "api.internal.contoso.com" ]}]}]}
# a URL list, then an ALLOW rule that matches it (SWP denies unmatched by default)gcloud network-security url-lists create approved-egress --location=europe-west1 \--values="github.com,*.github.com,*.pkg.dev,registry.internal"gcloud network-security gateway-security-policies rules create allow-approved \--location=europe-west1 --gateway-security-policy=egress-policy --priority=100 \--session-matcher="inUrlList(host(), 'projects/prod-42/locations/europe-west1/urlLists/approved-egress')" \--basic-profile=ALLOW --enabled
Created url list [approved-egress].Created rule [allow-approved] in gateway security policy [egress-policy].
Watch how each product treats traffic that matches nothing. Azure Firewall and Secure Web Proxy both deny by default, so an allow rule plus 'no match means drop' is the whole policy. AWS makes you opt into the drop, as above. Get the default right and the allowlist becomes the single, auditable chokepoint every connection must cross. Get it wrong and you have built either a proxy everyone routes around or one that waves everything through.
DNS: the first hop and the sneakiest exit
Every outbound connection opens with a question to the phone book. DNS (the domain name system) turns a name like api.github.com into an IP address, and it runs before the connection you are trying to filter even exists. That makes it the earliest place to say no. A workload with no route to the internet can usually still reach the cloud's DNS resolver, so blocking the lookup stops the connection before it starts.
DNS is also a smuggling tunnel, and this is the part teams miss. A resolver's job is to forward questions it cannot answer to whoever owns the domain. An attacker who owns evil.example points its nameserver at a machine they control, then has a compromised host encode stolen data into long, random-looking subdomain labels: MFRGG43F.evil.example, ORSXG5A7.evil.example, thousands of them. Your resolver forwards every one to that nameserver, which rebuilds the file on the far end. No connection to a blocked address, no proxy to route around. So the DNS layer does two jobs: block resolution of names you already know are bad, and log every query so you can hunt the tunnels that slip past. The tell is statistical. Baseline the normal length and rate of queries per domain, and if one domain suddenly takes thousands of 60-character labels an hour, that is a channel, and you want eyes on it.
# create the rule group, build a blocked-domain list, add a BLOCK rule (returns NXDOMAIN), bind the group to the VPCaws route53resolver create-firewall-rule-group \--name c2-blocking --creator-request-id frg-2026-07aws route53resolver create-firewall-domain-list \--name blocked-c2 --creator-request-id dl-2026-07aws route53resolver import-firewall-domains \--firewall-domain-list-id rslvr-fdl-0a1b2c3d4e5f6a7b8 \--operation REPLACE --domain-file-url s3://sec-config/blocked-domains.txtaws route53resolver create-firewall-rule \--firewall-rule-group-id rslvr-frg-9f8e7d6c \--firewall-domain-list-id rslvr-fdl-0a1b2c3d4e5f6a7b8 \--priority 100 --action BLOCK --block-response NXDOMAIN \--name block-known-c2 --creator-request-id fr-2026-07aws route53resolver associate-firewall-rule-group \--firewall-rule-group-id rslvr-frg-9f8e7d6c --vpc-id vpc-0abc123 \--priority 101 --name prod-vpc-dnsfw --creator-request-id assoc-2026-07
{"FirewallRuleGroup": {"Id": "rslvr-frg-9f8e7d6c","Name": "c2-blocking","Status": "COMPLETE"}}{"FirewallDomainList": {"Id": "rslvr-fdl-0a1b2c3d4e5f6a7b8","Name": "blocked-c2","Status": "COMPLETE"}}{"Id": "rslvr-fdl-0a1b2c3d4e5f6a7b8","Name": "blocked-c2","Status": "IMPORTING"}{"FirewallRule": {"FirewallRuleGroupId": "rslvr-frg-9f8e7d6c","Name": "block-known-c2","Priority": 100,"Action": "BLOCK","BlockResponse": "NXDOMAIN"}}{"FirewallRuleGroupAssociation": {"Id": "rslvr-frgassoc-0a1b2c3d4e5f6a7b8","VpcId": "vpc-0abc123","Status": "UPDATING"}}
# a response policy on the VPC, plus a rule that sinkholes bad names to 0.0.0.0gcloud dns response-policies create egress-guard \--networks=prod-vpc --description="block known-bad + log"gcloud dns response-policies rules create block-c2 \--response-policy=egress-guard --dns-name="*.evil-c2.example." \--local-data=name="*.evil-c2.example.",type=A,ttl=300,rrdatas=0.0.0.0
Created response policy [egress-guard].Created response policy rule [block-c2].
# make the firewall the VNet's DNS resolver so it can log and filter, and deny threat-intel hitsaz network firewall policy update -g hub-rg --name hub-fwpolicy \--enable-dns-proxy true --threat-intel-mode Deny
{"dnsSettings": {"enableProxy": true,"requireProxyForNetworkRules": null,"servers": []},"name": "hub-fwpolicy","provisioningState": "Succeeded","threatIntelMode": "Deny"}
The three block a lookup in different ways, and the difference shows up the moment you test. AWS returns NXDOMAIN (the 'no such name' answer), so the client believes the name does not exist. Google hands back a sinkhole address, a dead end you control (0.0.0.0 here, or an address you monitor) in place of the real one, so you get to see who tried. Azure's threat-intelligence mode denies names and addresses from Microsoft's feed once the firewall is acting as the DNS proxy, which is also what lets it log every query. Choose the sinkhole over NXDOMAIN when you would rather watch the attempt than hide the name.
The exfil that walks through the front door
Here is the attack your name-based allowlist does not catch. A build pipeline's credentials leak, and they are valid. The attacker uses them to read your data lake and copy it to a bucket in their own account on the same cloud. Every request rides a real API, with a real credential, to a hostname your firewall already trusts, because it is the same object-storage endpoint your app hits all day. The network sees nothing wrong. What stops this is a data perimeter: a rule that binds the data to a network and a set of identities, so a valid credential used from the wrong place is turned away by the service itself.
Google builds this in as VPC Service Controls, a fence drawn around managed services like Cloud Storage and BigQuery so data cannot move to a project outside the fence, even with a stolen credential. AWS assembles it from resource-policy conditions, a bucket policy that denies access unless the request arrives through your VPC endpoint (a private doorway into the service that never crosses the internet), plus Block Public Access. Azure does it with storage-account network rules: default-deny, then allow only your subnet or a private endpoint. Same goal on all three. The credential alone is not enough, the request also has to come from the right network.
# fence Cloud Storage and BigQuery inside a perimeter around one projectgcloud access-context-manager perimeters create prod_perimeter \--title="prod data perimeter" \--resources=projects/123456789 \--restricted-services=storage.googleapis.com,bigquery.googleapis.com \--policy=987654321
Create request issued for: [prod_perimeter]Waiting for operation [accessPolicies/987654321/servicePerimeters/prod_perimeter/create/1690000000000] to complete...done.Created service perimeter [prod_perimeter].
{"Version": "2012-10-17","Statement": [{"Sid": "DenyAccessFromOutsideVpce","Effect": "Deny","Principal": "*","Action": "s3:*","Resource": ["arn:aws:s3:::my-lake","arn:aws:s3:::my-lake/*"],"Condition": {"StringNotEqualsIfExists": { "aws:SourceVpce": "vpce-0a1b2c3d4e5f6a7b8" },"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }}}]}
# attach the resource perimeter (put-bucket-policy is silent on success), then read from off-perimeteraws s3api put-bucket-policy --bucket my-lake --policy file://bucket-policy.jsonaws s3 cp s3://my-lake/customers.parquet .
download failed: s3://my-lake/customers.parquet to ./customers.parquet An error occurred (AccessDenied) when calling the GetObject operation: Access Denied
# default-deny the storage account, then allow only the app subnetaz storage account update -g prod-rg -n prodlake --default-action Denyaz storage account network-rule add -g prod-rg --account-name prodlake \--vnet-name prod-vnet --subnet app-subnet
{"bypass": "AzureServices","defaultAction": "Deny","ipRules": [],"resourceAccessRules": null,"virtualNetworkRules": [{"action": "Allow","state": "Succeeded","virtualNetworkResourceId": ".../virtualNetworks/prod-vnet/subnets/app-subnet"}]}
Prove the door is shut, then keep watching
A firewall rule you have not tested is a hope, not a control. From inside the locked-down subnet, check three things: a known-bad name should fail to resolve, an unlisted host should be dropped, and an approved host should still work. If all three behave, the policy is real.
# 1. a known-bad name: the DNS firewall should refuse to resolve itdig blocked.evil-c2.example +noall +comments | grep status# 2. an unlisted host: the L7 firewall should drop the connectioncurl -sS --max-time 5 https://telemetry.unknown.example# 3. an approved host: must still succeedcurl -sS -o /dev/null -w 'HTTP %{http_code}\n' https://api.github.com
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 51920curl: (28) Connection timed out after 5001 millisecondsHTTP 200
Two records close the loop. Flow logs (VPC Flow Logs on AWS and Google, VNet flow logs on Azure) record who talked to whom, so you can rebuild an incident after the fact, and DNS query logs feed the tunnel hunt from earlier. One decision keeps the bill sane: send only internet-bound traffic through the firewall or proxy, and let private connectivity (PrivateLink on AWS, Private Service Connect on Google, Private Endpoints on Azure) carry your in-cloud service calls around it. That is cheaper, and it shrinks the surface where an attacker could spoof an SNI to look approved. Build every layer from landing-zone templates, the version-controlled blueprint each new account is stamped from, so your accounts, subscriptions, and projects come out identical instead of three snowflakes you have to reason about one at a time.
Try this
Run curl -s https://checkip.amazonaws.com on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a broad allowlist re-opens the door you just closed. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.