CoursesAzure securityAzure Firewall & egress control

Azure Firewall & egress control

Central FQDN allowlists, threat intel, IDPS.

Advanced35 min · lesson 5 of 15

A network security group (NSG, the per-network-card filter from the last lesson) is the passport check at the boarding gate. It reads an ID, source IP, port, and protocol, then waves the traveler through or turns them back. It cannot read the destination printed on the ticket, and it cannot open the bag. Azure Firewall is the customs checkpoint every departure funnels through. It reads where you are actually going, the domain name, and on the Premium tier it scans what you are carrying.

Most teams bolt the front door and leave the back door swinging. Inbound rules get all the attention. But data theft and command-and-control (C2, the channel malware uses to phone home for orders) both leave the same way: through egress, the outbound traffic leaving your network. It is the neglected half, and the half worth closing first. This lesson makes egress default-deny, so the standing answer for any outbound connection is 'no' until you say otherwise.

Why an NSG can't see where you're going

An NSG is a stateful access control list (ACL, a ranked list of allow and deny rules). It matches on the 5-tuple: source IP, source port, destination IP, destination port, and protocol. It is cheap, it is fast, and Azure pushes a copy to every network card. But it is blind above layer 4. It cannot express the rule you actually want, 'this VM may reach github.com and nowhere else,' because github.com resolves to a moving set of content delivery network (CDN) address ranges you cannot list in advance. And it cannot look inside a Transport Layer Security (TLS, the encryption under HTTPS) session to tell a legitimate package download from a data-exfiltration tunnel riding the same port 443. That blind spot is where modern intrusions live.

Azure Firewall is a managed, auto-scaling, stateful firewall. It is a cloud service, not a virtual machine you patch and babysit. It adds the two things an NSG lacks: rules that match on fully qualified domain names (FQDNs, full hostnames like pkg.githubusercontent.com), and, on the Premium tier, deep packet inspection (DPI, reading the actual contents of a connection rather than only its headers).

Deploy the hub firewall and force the path

The layout is hub-and-spoke: one firewall in a central hub virtual network (VNet), and your application workloads in spoke VNets peered to it. One guarded gate serving many buildings, instead of a guard per building. You get one place to enforce policy, one place to read logs, and one bill instead of many.

The step that matters most is not a rule. It is routing. You attach a user-defined route (UDR, a custom routing entry that overrides Azure's defaults) that sends 0.0.0.0/0, the default route meaning 'everything not otherwise matched,' to the firewall's private IP as the next hop. Then you associate that route table with every spoke workload subnet. Skip it, and your VMs keep Azure's built-in internet route, egress never touches the firewall, and every rule you write is decoration. The firewall itself has to live in a dedicated subnet named exactly AzureFirewallSubnet, sized at least a /26.

terminal
# The `az network firewall` commands live in an extension. Add it once; the CLI
# auto-installs on first use, but pin it in CI so builds are reproducible.
az extension add --name azure-firewall
# 1. Reusable policy. On a POLICY, --sku is the tier name (Basic/Standard/Premium).
# Premium is required for IDPS and TLS inspection.
az network firewall policy create -g rg -n hub-fw-policy --sku Premium
# 2. Dedicated subnet named EXACTLY AzureFirewallSubnet, /26 minimum.
az network vnet subnet create -g rg --vnet-name hub-vnet \
-n AzureFirewallSubnet --address-prefixes 10.0.1.0/26
# 3. On the FIREWALL, --sku is the deployment type (AZFW_VNet) and --tier is the
# tier. It needs a Standard static public IP, then an IP config anchoring it
# in AzureFirewallSubnet. It fully provisions when the IP config attaches.
az network public-ip create -g rg -n hub-fw-pip --sku Standard --allocation-method Static
az network firewall create -g rg -n hub-fw --sku AZFW_VNet --tier Premium \
--firewall-policy hub-fw-policy
az network firewall ip-config create -g rg -f hub-fw -n fw-ipconfig \
--public-ip-address hub-fw-pip --vnet-name hub-vnet
output
# policy create ->
{
"name": "hub-fw-policy",
"provisioningState": "Succeeded",
"sku": { "tier": "Premium" }
}
# firewall create ->
{
"name": "hub-fw",
"provisioningState": "Succeeded",
"sku": { "name": "AZFW_VNet", "tier": "Premium" }
}
# ip-config create (this is when it provisions; takes ~5-10 min) ->
{
"name": "hub-fw",
"provisioningState": "Succeeded",
"ipConfigurations": [
{ "name": "fw-ipconfig", "privateIPAddress": "10.0.1.4" }
]
}
terminal
# THE step people forget: force every spoke's egress to the firewall's private IP.
FW_IP=$(az network firewall show -g rg -n hub-fw \
--query "ipConfigurations[0].privateIPAddress" -o tsv)
echo "$FW_IP" # -> 10.0.1.4
az network route-table create -g rg -n spoke-rt
az network route-table route create -g rg --route-table-name spoke-rt \
-n default-to-fw --address-prefix 0.0.0.0/0 \
--next-hop-type VirtualAppliance --next-hop-ip-address "$FW_IP"
# Associate the route table with EACH workload subnet so 0.0.0.0/0 -> 10.0.1.4.
az network vnet subnet update -g rg --vnet-name spoke-vnet -n workloads \
--route-table spoke-rt
output
10.0.1.4
{
"name": "default-to-fw",
"addressPrefix": "0.0.0.0/0",
"nextHopType": "VirtualAppliance",
"nextHopIpAddress": "10.0.1.4",
"provisioningState": "Succeeded"
}
No UDR means no firewall
The allowlist only works if traffic reaches the firewall. Forget the 0.0.0.0/0 user-defined route on a spoke subnet, or bind the route table to the wrong subnet, and those VMs take Azure's default internet path straight out. Your policy still reports Succeeded, the portal looks healthy, and nothing is being filtered. Verify from the workload's own network card: run az network nic show-effective-route-table -g rg -n <workload-nic> and confirm the 0.0.0.0/0 entry has next hop 10.0.1.4. If it says Internet, your egress is wide open.

Modern Azure Firewall is driven by a Firewall Policy, a reusable object you build once and attach to many firewalls. Every Premium feature depends on it. Inside the policy, rules live in rule collection groups. It works like a binder with numbered tab dividers: you read the dividers lowest number first, and each divider holds a stack of rule collections. Every collection is either Allow or Deny. Three rule types exist: application rules (layer 7, the application layer where hostnames live, matching an FQDN plus protocol for HTTP and HTTPS), network rules (layers 3 and 4, matching an IP, a port, or an FQDN resolved through DNS, the domain name system, the internet's phone book that turns a name into an address), and NAT rules (inbound destination NAT, or DNAT, which forwards an outside hit to an internal address).

For an egress allowlist you want one application-rule collection that allows a short, approved list. Collections evaluate in priority order, lowest number first, and rules are terminating, so the first match wins. Anything no allow rule matches falls through to Azure Firewall's implicit final deny and gets dropped and logged. You never write a block rule; the deny is built in. One subtlety trips people up. Among collections of the same type, a Deny beats an Allow only when its priority number is lower, so ordering is by number, not by which action sounds scarier. Rule types carry their own fixed order on top of that: network rules are always evaluated before application rules, whatever the priority numbers say.

terminal
# Rule collection group = ordered container; lower priority number goes first.
az network firewall policy rule-collection-group create \
-g rg --policy-name hub-fw-policy -n egress-rcg --priority 200
# Allow ONLY these FQDNs over 443. Everything else hits the implicit final deny.
az network firewall policy rule-collection-group collection add-filter-collection \
-g rg --policy-name hub-fw-policy --rule-collection-group-name egress-rcg \
--name egress-allow --collection-priority 100 --action Allow \
--rule-name approved --rule-type ApplicationRule \
--source-addresses 10.1.0.0/16 --protocols Https=443 \
--target-fqdns "*.ubuntu.com" "*.github.com" "login.microsoftonline.com"
output
{
"name": "egress-rcg",
"priority": 200,
"provisioningState": "Succeeded",
"ruleCollections": [
{
"action": { "type": "Allow" },
"name": "egress-allow",
"priority": 100,
"rules": [
{
"ruleType": "ApplicationRule",
"name": "approved",
"protocols": [ { "protocolType": "Https", "port": 443 } ],
"sourceAddresses": [ "10.1.0.0/16" ],
"targetFqdns": [ "*.ubuntu.com", "*.github.com", "login.microsoftonline.com" ]
}
]
}
]
}
The one enforced egress path
1Spoke VM egress
tries to reach 0.0.0.0/0 (anywhere)
2UDR next hop
0.0.0.0/0 -> firewall private IP 10.0.1.4
3Firewall policy
FQDN allowlist + threat intel + IDPS
4Allow or implicit deny
first match wins; unmatched dropped and logged
The firewall can only judge traffic that reaches it. The UDR is what makes the path mandatory; skip it and the entire allowlist is bypassed.

Turn the allowlist into a sensor

Three switches turn the allowlist from a filter into a sensor. Two of them ship from the Standard tier up; only the signature engine and the deepest inspection need Premium.

Threat-intelligence filtering (Standard and Premium) is a neighborhood watch bulletin. The firewall checks every flow against Microsoft's curated feed of known-malicious IPs and domains, and this check runs before your own rules. Set it to Deny and it blocks and logs traffic to command-and-control and phishing infrastructure even against a destination a too-broad allow rule would otherwise wave through. DNS Proxy (also Standard and up) fixes a quieter problem. Point the spokes' DNS resolver at the firewall, and the firewall becomes the one receptionist everybody asks for phone numbers. Now the firewall and the client resolve a name to the same address, so your FQDN rules enforce on the exact IP the client will dial, closing the gap between looking a name up and connecting to it.

IDPS, the intrusion detection and prevention system (Premium only), is a guard with a thick book of mugshots. It is a signature engine holding tens of thousands of patterns for known exploits, malware, and C2 traffic, and it inspects flows for a match. Set --idps-mode Deny and a match is blocked outright. Premium also adds TLS inspection. The firewall steams open the sealed envelope: it terminates the outbound HTTPS session, reads inside, then re-encrypts and forwards it, so IDPS can see the actual payload instead of judging only the server name indication (SNI, the cleartext hostname in the TLS handshake). It is powerful, and it has a cost. It needs a trusted intermediate certificate authority (CA) your clients accept, and it breaks certificate-pinned apps that refuse any certificate but their own. Turn it on for scoped traffic, not everything.

terminal
# Threat intel blocks Microsoft's known-bad feed. DNS proxy makes the firewall
# resolve names so FQDN rules can't be dodged. IDPS is signature prevention.
az network firewall policy update -g rg -n hub-fw-policy \
--threat-intel-mode Deny --enable-dns-proxy true --idps-mode Deny
# Confirm all three flipped:
az network firewall policy show -g rg -n hub-fw-policy \
--query "{ti:threatIntelMode, dns:dnsSettings.enableProxy, idps:intrusionDetection.mode}"
output
{
"ti": "Deny",
"dns": true,
"idps": "Deny"
}
FQDN network rules leak without DNS proxy
If you filter a non-HTTP flow by FQDN in a network rule, but the spoke VMs still resolve DNS through their own resolver instead of the firewall, the firewall and the client can resolve the same name to different IPs. The rule matches the firewall's answer while the VM connects to a different address, and the traffic slips past your allowlist. Enable --enable-dns-proxy true and set the firewall's private IP as the spokes' DNS server. Application rules match on the TLS SNI and Host header, so they are unaffected. Network-rule FQDNs absolutely are.

Prove it: read the denied egress

A control you cannot observe is a control you cannot trust. Stream the firewall's logs to a Log Analytics workspace (LAW, Azure's queryable log store) using resource-specific tables (AZFWApplicationRule, AZFWThreatIntel, AZFWIdpsSignature), then query for denies with KQL (Kusto Query Language, the language these logs speak). A single compromised host beaconing to blocked destinations shows up on the first query. That row is the detection.

If you see allow rows for approved traffic and deny rows for everything else, the path and the policy are both live. The same tables feed alerting. AZFWThreatIntel rows are hits on Microsoft's known-bad feed. AZFWIdpsSignature rows name the exact signature that fired. Wire a Microsoft Sentinel (Azure's cloud-native SIEM, security information and event management) rule on any Deny in the last five minutes and the beacon pages someone.

terminal
# Stream firewall logs to a workspace using resource-specific (Dedicated) tables.
az monitor diagnostic-settings create -n fw-diag \
--resource $(az network firewall show -g rg -n hub-fw --query id -o tsv) \
--workspace <law-resource-id> --export-to-resource-specific true \
--logs '[{"category":"AZFWApplicationRule","enabled":true},
{"category":"AZFWThreatIntel","enabled":true},
{"category":"AZFWIdpsSignature","enabled":true}]'
# What got blocked in the last hour? This row IS the detection.
az monitor log-analytics query -w <workspace-guid> -o table --analytics-query "
AZFWApplicationRule
| where TimeGenerated > ago(1h) and Action == 'Deny'
| project TimeGenerated, SourceIp, Fqdn, Action
| take 5"
output
TimeGenerated SourceIp Fqdn Action
---------------------------- ---------- --------------------- ------
2026-07-14T09:41:12.663Z 10.1.2.7 evil-c2.example.net Deny
2026-07-14T09:39:55.114Z 10.1.2.7 pastebin.com Deny
Quick check
01An NSG is already stateful and covers both directions. So why can't it enforce 'this VM may reach github.com and nowhere else'?
Incorrect — NSGs are stateful and do track return flows, so that isn't the limitation.
Correct — NSGs stop at layer 3/4, so a moving-target FQDN over encrypted 443 is out of reach.
Incorrect — NSGs filter both directions, and outbound rules work fine at layer 4.
Incorrect — NSGs have no tiers and no FQDN feature at all; that job belongs to the firewall.
02You filter a non-HTTP flow by FQDN in a network rule, but the spoke VMs resolve DNS through their own resolver, not the firewall. What actually happens?
Incorrect — the firewall resolves names fine; the failure is a mismatch, not an inability to resolve.
Incorrect — network-rule FQDNs get resolved to IPs, so whose resolver answers matters a great deal.
Correct — without DNS proxy the two sides can disagree on the IP, and the client's chosen IP wins the connection.
Incorrect — application rules match on the TLS SNI and Host header, not on a resolved IP.
03Your application rule allows only *.github.com over 443, yet a compromised spoke VM opens a 443 connection to an arbitrary attacker IP and it succeeds. The firewall logs show no rows at all for that VM. Most likely cause?
Correct — no traffic reaching the firewall means no rule evaluation and no log line, which matches the silence exactly.
Incorrect — threat intel wouldn't wave an arbitrary IP through an allowlist, and it would still write a log row.
Incorrect — a bad priority would still route traffic through the firewall and log a deny, not produce total silence.
Incorrect — application (FQDN) rules work on Standard, and traffic that reached the firewall would still be logged.

Cost, SNAT ports, and where this stops

Azure Firewall is not free plumbing. The Premium tier this lesson deploys runs around $1,277 a month in a typical US region to exist, before a byte of traffic moves. Standard is roughly $912 a month, and a cheaper Basic tier exists for small shops but drops IDPS entirely. On top of the hourly fee, Standard and Premium both add about $0.016 per gigabyte processed, the same rate, so Premium's premium is the fixed hourly cost, not the data. That math is the whole argument for centralizing one firewall per hub or region and never one per spoke.

At scale, watch SNAT ports. The firewall source-NATs your egress (SNAT, source network address translation, rewriting many private IPs so they share one public IP), the way an office switchboard shares a handful of outside phone lines among hundreds of desks. Each public IP grants about 2,496 of those lines. High-fan-out outbound workloads can exhaust them and start dropping connections with no obvious error. Add public IP addresses, or attach a NAT gateway for a far larger pool, and alarm on the SNAT port utilization metric before you hit the wall.

Know the boundary. The firewall inspects only traffic that reaches it, so it complements your per-subnet NSGs, it does not replace them. That is defense in depth: two different controls with two different failure modes. And it does nothing for traffic you would rather never put on the public internet in the first place, your calls to Azure platform services (PaaS, platform-as-a-service, managed offerings like Storage, SQL, and Key Vault). The next lesson, Private Endpoints, pulls those services onto private IPs inside your VNet, so storage and secret traffic never needs an egress rule at all. Firewall for the internet you must reach. Private Endpoints for the Azure you should not.

Try this

Run az extension add --name azure-firewall 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: no UDR means no firewall. 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