A flat allow-all egress is the single most common gap I find in otherwise mature cloud estates. Workloads can reach any IP on 443, so a compromised pod, a leaked credential, or a poisoned dependency has an open exfiltration channel and no record of where the bytes went. The fix is architectural, not a knob: every spoke’s path to the internet must terminate at one inspected chokepoint that allowlists destinations by FQDN, logs every flow, and – where the threat model demands it – breaks TLS to see inside. The engine that inspects is a commodity; the hard, failure-prone work is the routing and DNS design that guarantees no workload can reach the internet except through that chokepoint, plus the discipline that keeps the chokepoint from becoming a single point of failure. This walkthrough builds that end to end across Azure and AWS.
1. Why centralize egress at all
Three properties justify the blast radius of a shared chokepoint:
- Auditability. One flow log of record. “Did anything in prod talk to
*.rulast Tuesday” becomes a single KQL query, not a fan-out across 80 accounts. - Data-exfil control. Allowlisting destinations (not just blocking known-bad) means a foothold can only call out to FQDNs you have explicitly sanctioned, collapsing the exfil surface from “the entire internet” to “the 40 endpoints this app legitimately needs.”
- A single allowlist of record. Package mirrors, OS update endpoints, SaaS APIs, OCSP/CRL responders – what production may reach lives in one policy, reviewed in one place, versioned in Git.
Centralization is a tradeoff: you concentrate both control and risk. The rest of this article is about earning the right to do that – forcing all traffic through the chokepoint, and making the chokepoint redundant enough that it isn’t a liability.
2. Transparent firewall vs explicit forward proxy
Two enforcement models, and the choice ripples into client configuration, TLS handling, and what “bypass” even means.
| Dimension | Transparent firewall | Explicit forward proxy |
|---|---|---|
| Client awareness | None; traffic is routed to it | Clients configured with HTTP(S)_PROXY or PAC |
| Enforcement lever | Routing (UDR / route table) | Application honoring proxy settings |
| Destination identity | TLS SNI / HTTP Host header | CONNECT host:443 from the client |
| Non-HTTP traffic | Inspected (it’s in the data path) | Bypasses unless separately forced |
| Bypass risk | Misrouted/asymmetric flows | App ignores proxy env vars |
The transparent model – Azure Firewall, AWS Network Firewall – sits inline as a bump in the wire. You steer traffic to it with routes; it reads the TLS ClientHello’s SNI or the HTTP Host to make FQDN decisions. Clients need zero configuration, which is its great virtue at scale: a new spoke inherits enforcement the moment its route table points at the chokepoint.
The explicit proxy – Squid, a cloud web-proxy SKU, a vendor SWG – requires every client to be told where the proxy is. The client opens a CONNECT example.com:443 tunnel, so the proxy learns the FQDN from the client itself, no SNI sniffing required, which makes domain identity unambiguous and robust against ESNI/ECH and domain fronting. The cost: anything ignoring HTTPS_PROXY – a statically linked Go binary, a misconfigured container, an SDK that only reads its own config – silently bypasses it. Strong designs run both: explicit proxy for managed clients that respect it, and a transparent firewall as the backstop that catches everything else and denies direct egress.
3. FQDN filtering mechanics: SNI/Host vs full break-and-inspect
There are two fundamentally different ways to enforce an FQDN allowlist, and conflating them is the root of most “why did my rule not match” tickets.
SNI / Host inspection (no decryption). The firewall reads the unencrypted Server Name Indication field in the TLS ClientHello, or the Host: header on plain HTTP. It never decrypts. This is cheap, breaks nothing, and is the right default for most egress. Its limits:
- An attacker can send a benign SNI and a different
Hostonce the tunnel is up (domain fronting), or use Encrypted Client Hello (ECH) so there is no cleartext SNI. - You see the domain, not the URL path or payload.
https://drive.google.com/legitandhttps://drive.google.com/exfilare indistinguishable.
Break-and-inspect (full TLS termination). The firewall presents its own certificate to the client (signed by a CA the client trusts), terminates TLS, opens a fresh TLS session to the real server, and inspects cleartext in the middle. Now you can filter on URL, run IDPS on the payload, and DLP the body. The cost is real: you must distribute a trusted CA to every client, you break certificate pinning, and you take on the liability of decrypting traffic.
My rule of thumb: SNI/Host filtering for the broad allowlist; break-and-inspect only for segments where the payload threat justifies it (e.g., egress from a PCI zone). Don’t decrypt the whole estate by reflex.
Application rule in Azure Firewall Policy doing SNI-based FQDN allowlisting – no decryption:
resource appRules 'Microsoft.Network/firewallPolicies/ruleCollectionGroups@2023-11-01' = {
parent: firewallPolicy
name: 'egress-app-rules'
properties: {
priority: 300
ruleCollections: [
{
ruleCollectionType: 'FirewallPolicyFilterRuleCollection'
name: 'allow-egress-fqdns'
priority: 1000
action: { type: 'Allow' }
rules: [
{
ruleType: 'ApplicationRule'
name: 'pkg-and-saas'
sourceAddresses: [ '10.0.0.0/8' ]
protocols: [ { protocolType: 'Https', port: 443 } ]
targetFqdns: [
'login.microsoftonline.com'
'management.azure.com'
'*.blob.core.windows.net'
'registry-1.docker.io'
'production.cloudflare.docker.com'
]
}
]
}
]
}
}
The equivalent in AWS Network Firewall is a stateful rule group keyed on TLS SNI. Note the explicit tls.sni match and that you must also pass the DNS resolution that precedes it:
resource "aws_networkfirewall_rule_group" "egress_sni_allow" {
capacity = 200
name = "egress-sni-allowlist"
type = "STATEFUL"
rule_group {
rules_source {
rules_string = <<-EOT
pass tls $HOME_NET any -> $EXTERNAL_NET 443 (tls.sni; content:"registry-1.docker.io"; startswith; nocase; flow:to_server; sid:1001;)
pass tls $HOME_NET any -> $EXTERNAL_NET 443 (tls.sni; dotprefix; content:".blob.core.windows.net"; endswith; nocase; flow:to_server; sid:1002;)
drop tls $HOME_NET any -> $EXTERNAL_NET 443 (flow:to_server; msg:"egress-default-deny-tls"; sid:1999;)
EOT
}
stateful_rule_options { rule_order = "STRICT_ORDER" }
}
}
dotprefix plus a leading-dot content matches a wildcard subdomain in Suricata without also matching evilblob.core.windows.net.attacker.com.
4. Forced-routing design: spoke UDRs and the 0.0.0.0/0 default
This is where deployed chokepoints quietly inspect nothing. Both clouds ship an implicit “go straight to the internet” route, and peering/attaching a spoke does not remove it.
Azure. Every subnet has a system route 0.0.0.0/0 -> Internet. To force egress through the hub firewall you override it with a UDR whose next hop is the firewall’s private IP, then associate that route table with every workload subnet.
RG=rg-hub-prod
FW_PRIVATE_IP=10.0.1.4 # Azure Firewall private IP
az network route-table create -g "$RG" -n rt-spoke-egress
az network route-table route create -g "$RG" \
--route-table-name rt-spoke-egress \
--name default-to-firewall \
--address-prefix 0.0.0.0/0 \
--next-hop-type VirtualAppliance \
--next-hop-ip-address "$FW_PRIVATE_IP"
# Associate with each spoke workload subnet (repeat per subnet)
az network vnet subnet update \
-g rg-spoke-app -n snet-workload --vnet-name vnet-spoke-app \
--route-table rt-spoke-egress
AWS. Spoke VPCs must have no IGW and no NAT of their own. Their route tables send 0.0.0.0/0 to the Transit Gateway; the inspection VPC owns the only path to an IGW (behind the firewall and a NAT gateway). The spoke literally has no local internet exit.
resource "aws_route" "spoke_default_to_tgw" {
route_table_id = aws_route_table.spoke.id
destination_cidr_block = "0.0.0.0/0"
transit_gateway_id = aws_ec2_transit_gateway.hub.id
}
The asymmetric-routing pitfall
The single most common silent failure: forward and return traffic take different paths, so the stateful firewall sees only half a flow and drops it. The classic trigger is spoke-to-spoke or ingress traffic where the inbound leg is routed through the chokepoint but the return leg goes direct.
- AWS: enable appliance mode on the inspection-VPC TGW attachment. Without it, the TGW may hash the two directions of a flow to different AZs’ firewall endpoints, and the per-endpoint stateful engine drops the asymmetric half. Appliance mode pins both directions to the same endpoint.
resource "aws_ec2_transit_gateway_vpc_attachment" "inspection" {
subnet_ids = var.firewall_subnet_ids
transit_gateway_id = aws_ec2_transit_gateway.hub.id
vpc_id = aws_vpc.inspection.id
appliance_mode_support = "enable"
transit_gateway_default_route_table_association = false
}
- Azure: ensure the return path to the spoke (and from on-prem) also points at the same firewall. Because the firewall SNATs internet egress by default, return symmetry holds for internet flows; the asymmetry bites on east-west, where you must put the firewall in both spokes’ next hop for the other spoke’s range.
5. DNS dependency: why FQDN rules need consistent resolution
FQDN filtering on a transparent firewall is only as reliable as the name resolution behind it. Two distinct problems:
-
Network-rule FQDNs (not application rules) resolve the name to IPs on the firewall and match on IP. If the client resolves it to a different IP than the firewall did – common with geo-DNS or short-TTL CDNs – the client connects to an IP the firewall never allowlisted, and the flow is denied even though the FQDN was “allowed.”
-
A workload using its own resolver (8.8.8.8, a hardcoded resolver) can resolve names the firewall’s policy doesn’t know about, and – worse – DNS itself becomes an exfil channel over port 53.
The fix is DNS proxy: point all spokes at the firewall as their DNS server and have it proxy queries upstream. Now firewall and client share one resolution, FQDN network rules line up, and you get DNS-layer logging plus the ability to block resolution of non-allowlisted names.
// Azure Firewall Policy: enable DNS proxy so spokes resolve via the firewall
resource fwPolicy 'Microsoft.Network/firewallPolicies@2023-11-01' = {
name: 'fwpol-egress'
location: location
properties: {
dnsSettings: {
servers: [ '168.63.129.16' ] // Azure-provided DNS, or your private resolver
enableProxy: true
}
}
}
Then set the VNet DNS server to the firewall’s private IP (not the subnet), so every workload’s lookups traverse it:
az network vnet update -g rg-spoke-app -n vnet-spoke-app \
--dns-servers 10.0.1.4
Block outbound UDP/TCP 53 to the internet for everything except the firewall/resolver itself. Otherwise a workload bypasses your DNS proxy and tunnels data out over DNS, and your FQDN allowlist is theater. This is the most-missed control in the whole design.
On AWS, force resolution through Route 53 Resolver inbound/outbound endpoints (or a DNS firewall rule group) and likewise deny direct 53 egress in the Network Firewall policy.
6. TLS inspection: CA distribution, pinning, and exemptions
When you do need to see inside TLS, the mechanics are unforgiving.
CA distribution. The firewall needs an intermediate CA whose private key it uses to mint per-site leaf certs on the fly. In Azure Firewall Premium you generate an intermediate CA, store it in Key Vault, and reference it from policy; the firewall reads it via a managed identity. Critically, every client must trust the issuing CA or you get a wall of certificate errors.
# Grant the firewall's managed identity access to the CA cert in Key Vault
az keyvault set-policy -n kv-fw-ca \
--object-id "$FW_IDENTITY_OBJECT_ID" \
--secret-permissions get \
--certificate-permissions get
Distribute the CA to clients via the OS trust store – Group Policy on Windows, a config-management push on Linux:
# Windows: import the inspection CA into the machine Root store via GPO/script
Import-Certificate -FilePath "C:\certs\egress-inspect-ca.cer" `
-CertStoreLocation Cert:\LocalMachine\Root
# Ubuntu: add CA to the system trust store
sudo cp egress-inspect-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
Pinning breakage and what to exempt. Certificate pinning – where an app embeds the expected server cert/public key and refuses anything else – breaks under break-and-inspect, because your minted cert isn’t the pinned one. You cannot fix this from the network; you must exempt pinned destinations from decryption (fall back to SNI-only filtering). Common offenders to exempt:
- OS/software update channels (Windows Update, Apple software update, package managers that pin).
- Banking, payment, and many mobile apps.
- Anything doing mutual TLS – inspection breaks the client-auth handshake.
- Certificate-validation endpoints (OCSP/CRL) – decrypting these can deadlock validation.
In Azure you exempt with a TLS-inspection bypass list on the application rule (terminateTLS: false for those FQDNs); in Squid you use an ssl::server_name ACL to splice instead of bump. Maintain the exemption list as code and review it – every exemption is a small hole, and “exempt *” defeats the purpose.
7. Scaling and resilience: don’t build a single chokepoint outage
A centralized chokepoint is, by construction, a place where one outage takes down all internet egress. Engineer against it.
- Throughput. Azure Firewall Premium autoscales but has documented per-instance and aggregate limits, and TLS inspection roughly halves effective throughput because every flow is decrypted and re-encrypted. Size with that 2x tax in mind. AWS Network Firewall scales per-endpoint; provision an endpoint per AZ and watch the per-flow and per-endpoint limits.
- Zone redundancy. Deploy the firewall across all availability zones. In Azure, set
zones: ['1','2','3']on the firewall and its public IP. In AWS, a firewall endpoint in every AZ with spoke routing landing in the local AZ avoids a cross-AZ dependency and shrinks the AZ-failure blast radius. - No cross-AZ data-path dependency. Route each AZ’s traffic to that AZ’s firewall endpoint, with failover to a healthy AZ. Sending all spokes to one AZ’s endpoint turns an AZ outage into a total egress outage.
- Capacity headroom and SNAT. Egress SNAT ports are finite. A high-fan-out workload (many short connections to many destinations) exhausts them, producing intermittent failures that look like firewall flakiness. Add public IPs (Azure) or NAT gateways with sufficient EIPs (AWS) to expand the port pool, and alert on SNAT utilization.
// Zone-redundant Azure Firewall
resource azfw 'Microsoft.Network/azureFirewalls@2023-11-01' = {
name: 'azfw-hub'
location: location
zones: [ '1', '2', '3' ]
properties: {
sku: { name: 'AZFW_VNet', tier: 'Premium' }
firewallPolicy: { id: fwPolicy.id }
}
}
8. Bypass-hunting: prove no spoke can reach the internet outside the chokepoint
A control you haven’t tried to defeat is a control you don’t have. Treat this as an audit you run on every new spoke and on a schedule.
Effective-route audit. Confirm 0.0.0.0/0 on every workload NIC resolves to the firewall, not Internet:
# Azure: dump the EFFECTIVE routes on a workload NIC and assert the default points at the FW
az network nic show-effective-route-table \
-g rg-spoke-app -n nic-app-01 -o table
# Look for: 0.0.0.0/0 VirtualAppliance 10.0.1.4 (NOT 0.0.0.0/0 Internet)
Active probe from inside a spoke. From a workload, a direct connection to an arbitrary IP (bypassing DNS) must fail, while an allowlisted FQDN must succeed:
# Should FAIL (no route except via FW, and FW denies non-allowlisted): direct IP egress
curl -sS --max-time 5 https://1.1.1.1/ ; echo "exit=$?"
# Should FAIL: a non-allowlisted domain
curl -sS --max-time 5 https://example-not-allowlisted.com/ ; echo "exit=$?"
# Should SUCCEED: an allowlisted FQDN, proving the chokepoint path works
curl -sS --max-time 5 https://registry-1.docker.io/v2/ ; echo "exit=$?"
DNS-exfil probe. Confirm direct DNS to a public resolver is blocked:
# Should TIME OUT / fail if direct 53 egress is denied
dig @8.8.8.8 example.com +time=3 +tries=1
AWS effective routing. Verify the spoke has no IGW route and the default goes to the TGW:
aws ec2 describe-route-tables \
--filters "Name=vpc-id,Values=$SPOKE_VPC_ID" \
--query "RouteTables[].Routes[?DestinationCidrBlock=='0.0.0.0/0']" --output table
# Expect TransitGatewayId, never an InternetGatewayId
If any probe behaves the wrong way, you have a bypass: a spoke subnet without the UDR, a leftover NAT/IGW, a public IP attached directly to a VM, a DNS misconfig, or a Service Endpoint that lets traffic skip the firewall. Hunt all of them.
Verify
Run these after every change to policy, routing, or DNS:
- Routing:
show-effective-route-table(Azure) /describe-route-tables(AWS) shows0.0.0.0/0to the firewall/TGW on every spoke subnet – never toInternet/IGW. - DNS:
nslookupfrom a spoke resolves via the firewall IP; the firewall DNS proxy/Resolver logs show the query. Directdig @8.8.8.8fails. - Allow path: an allowlisted FQDN over 443 returns 200; the firewall log shows an Allow with the matching rule name.
- Deny path: a non-allowlisted FQDN and a direct-IP connection both fail; the log shows a Deny / default-drop.
- TLS inspection (where enabled): the served leaf cert chains to your inspection CA; an exempted domain still serves the origin’s real cert (proving the bypass list works).
- Resilience: simulate one AZ’s firewall endpoint loss and confirm egress continues via another AZ.
Enterprise scenario
A platform team running ~70 spoke VNets behind a single zone-redundant Azure Firewall Premium hub turned on full TLS break-and-inspect estate-wide to satisfy a new DLP mandate. Within an hour, Windows Update stalled across thousands of VMs, the Linux fleet’s apt and pip jobs failed certificate validation, and – most alarming – a payment-processing tier silently lost connectivity to its acquirer, which used mutual TLS. The root cause was uniform: break-and-inspect substituted the firewall’s minted certificate, which broke pinning, broke client-cert auth, and tripped update channels that validate Microsoft’s own chain.
The constraint: they could not abandon decryption (the DLP mandate was non-negotiable for the data-handling zones), but they could not decrypt everything either. The solution was a targeted decryption policy with a maintained-as-code exemption list. They scoped break-and-inspect to the application rules sourced from the regulated subnets only, and added an explicit TLS-bypass list for update channels, the acquirer’s mTLS endpoints, and OCSP/CRL responders – those fell back to SNI-only filtering, still allowlisted, just not decrypted. The default for the rest of the estate became SNI/Host filtering.
// Regulated-zone rule: inspect (decrypt). General estate uses SNI-only rules elsewhere.
{
ruleType: 'ApplicationRule'
name: 'regulated-egress-inspect'
sourceAddresses: [ '10.40.0.0/16' ] // PCI subnets only
protocols: [ { protocolType: 'Https', port: 443 } ]
terminateTLS: true // break-and-inspect for this scope
targetFqdns: [ 'api.partner-saas.com', '*.internal-dlp-egress.com' ]
}
// Exemption (no decrypt) for pinned / mTLS / update endpoints, applied estate-wide:
{
ruleType: 'ApplicationRule'
name: 'tls-inspection-exempt'
sourceAddresses: [ '10.0.0.0/8' ]
protocols: [ { protocolType: 'Https', port: 443 } ]
terminateTLS: false // SNI-only; do NOT decrypt
targetFqdns: [
'acquirer.payments.example' // mutual TLS
'*.windowsupdate.com'
'ocsp.digicert.com'
]
}
The lesson the team codified afterward: decryption is opt-in per scope, exemptions are reviewed like firewall rules, and “inspect everything” is never the default. They also added a pre-merge check that fails the pipeline if a new terminateTLS: true rule lacks an owner and a documented data-handling justification.