Skip to main content

Appendix D.4: AVD East-West Segmentation (NSG) Reference

A multi-pool AVD deployment (Multi-Pool Variant) puts each user population on its own subnet, but subnets in a VNet can talk to each other freely by default. If one session host is compromised, nothing stops it from probing SMB, RDP, or WinRM on every session host in every other pool. This appendix closes that gap with a single Network Security Group (NSG) carrying a single deny rule, associated to every session-host subnet.

The result: pool-to-pool traffic is denied in both directions, VM-to-VM traffic within a pool is denied too, and nothing about the AVD user experience changes.

Why the Firewall Cannot Do This

The Azure Firewall from Appendix D.2 governs north-south traffic only. The 0.0.0.0/0 UDR from the runbook's Step 4 captures internet-bound egress, but traffic between two subnets inside the VNet follows the more-specific system routes and goes subnet-to-subnet directly, never touching the firewall. The east-west layer belongs to NSGs: L3/L4 filtering evaluated at the subnet boundary, at no cost. Microsoft's network design guidance pairs the two exactly this way.

Why One Deny Rule Is Safe

Two facts make this pattern nearly risk-free for AVD:

  1. Session hosts need zero inbound network access. The client and the session host each establish outbound connections to the AVD control plane, which brokers the session (reverse connect). Every required endpoint in Microsoft's FQDN list is outbound, and the prerequisites state explicitly that no inbound ports are required. Denying all VNet-sourced inbound does not touch the user session.
  2. East-west traffic is only permitted by one default rule. Every NSG ships with AllowVnetInBound at priority 65000. Custom rules occupy priorities 100 through 4096 and always evaluate first (default rules reference), so one custom deny overrides it.

The deny rule's source is the VirtualNetwork service tag, deliberately not *:

TrafficWhy it survives
AVD user sessionsBrokered over outbound 443 (reverse connect); never VNet-sourced inbound
Azure Load Balancer health probesArrive under the AzureLoadBalancer tag; still matched by the default allow at 65001
IMDS (169.254.169.254) and WireServer (168.63.129.16)Protected at the Azure fabric layer; NSG rules do not affect them
Pool-to-pool SMB/RDP/WinRM probingDenied: matches VirtualNetwork source, and the deny at 4096 beats AllowVnetInBound at 65000
VM-to-VM within one poolDenied: intra-subnet traffic is evaluated by the subnet's NSG too. Session hosts have no reason to talk to each other, so this is free lateral-movement reduction, not collateral damage

Because the rule is identical for every population, one NSG associated to all session-host subnets is the whole design. Pool A to pool B traffic is denied inbound at B's subnet, and vice versa.

Do not add an outbound mirror rule

Inbound deny on every session-host subnet already blocks east-west in both directions. An outbound VirtualNetwork deny looks like defense in depth but is a trap: the firewall's private IP sits inside the VirtualNetwork tag, so any flow addressed to it directly (Azure Firewall DNS proxy, explicit-proxy configurations) would be cut off.

Pre-Flight: Inventory East-West Dependencies

The deny rule blocks all VNet-sourced inbound to session hosts, including flows you may have quietly depended on. Check each of these before associating the NSG; each exception is one allow rule above priority 4096.

DependencySymptom if missedException rule (above 4096)
Azure Bastion or a jump box used for admin RDP to session hostsAdmin RDP fails after associationAllow TCP 3389 from the Bastion/management subnet CIDR
Private endpoints in the VNet that other subnets' NSGs would block (FSLogix profile shares on Azure Files, Key Vault)Profile load or app failuresSession hosts initiating outbound to a private endpoint are unaffected by this NSG; only add rules if the private endpoint's own subnet carries a deny
RDP Shortpath for managed networks (clients reaching session hosts from a VPN or ExpressRoute path)Sessions silently fall back to TCP reverse connect, or Shortpath failsAllow UDP 3390 from the client address space (port per the RDP Shortpath doc; confirm there before adding). Internet-based clients use Shortpath for public networks, which is outbound STUN/TURN and unaffected
Anything that scans or backs up session hosts over the network from inside the VNetScan/backup job failuresAllow the specific ports from the scanner's subnet. Azure Backup for VMs uses the fabric agent, not VNet inbound, and is unaffected

An enclave built per this book's runbook (Entra-joined session hosts, no domain controllers, CUI in SharePoint rather than on VNet file shares) typically has no legitimate east-west flows and needs no exceptions.

Delivery Optimization: the one default flow this blocks

One Windows default does flow VM-to-VM inside a pool: Delivery Optimization peer caching. Windows clients ship with DO in LAN download mode, sharing update content with peers behind the same public IP over TCP 7680, and session hosts SNAT-ing through the firewall's public IP qualify as peers (how DO works). The deny rule blocks that port, and the failure mode is graceful by design: Microsoft documents that when 7680 is blocked, peer-to-peer is disabled and "devices can still download content using HTTP" from the CDN (DO configuration). Updates keep flowing through the firewall's existing WindowsUpdate rules; no user impact, no broken patching.

The cost is bandwidth, not function: every session host pulls updates individually through the firewall instead of sharing pieces with neighbors, which adds some firewall-processed gigabytes per patch cycle on a large fleet.

Expect to see it in the flow logs. Session-host-to-session-host denies on TCP 7680, in both directions between the same pair of private IPs, are Delivery Optimization peering and the deny rule working as designed. They form the steady-state baseline the queries below sit on top of.

Do not recover peering with an allow rule. An NSG rule allows the port, not the DO protocol: a VirtualNetwork-wide TCP 7680 allow hands back a generic cross-pool channel any attacker-controlled listener could use, and a same-subnet-only allow is not expressible in the single shared NSG (the self-referencing CIDR differs per subnet). Accept the CDN fallback.

Optional tidy-up: make the client configuration match network reality so hosts stop attempting peer connections that will always fail. Create an Intune Settings Catalog policy setting Delivery Optimization Download mode to HTTP only, no peering (0) (Intune DO settings reference), assigned to the AVD dynamic device group (EID AVD Devices from the runbook's Step 9), following the same pattern as the runbook's Step 12 power-options policy. Scope it to the AVD device group only: physical endpoints elsewhere in the tenant still benefit from peer caching, and the Layer 1 baseline (Appendix B) deliberately leaves Delivery Optimization unconfigured.

Check for a conflict with the OIB Layer 2 Delivery Optimisation policy

The OIB Layer 2 set includes a Delivery Optimisation policy. If that policy is deployed and also configures Download mode, assigning both it and this HTTP-only policy to overlapping device groups produces a settings conflict on the AVD fleet. Before assigning, open the OIB policy and check whether it sets Download mode; if it does, exclude the AVD device group from the OIB policy's assignment so this policy is the only one configuring DO on session hosts.

The Canonical Template

The template deploys the NSG and its single rule. Deployable source: avd-nsg.bicep. The listing below is generated from that file at build time and cannot drift from it.

param location string = 'usgovvirginia'
param nsgName string = 'nsg-avd-hostpools-eastwest'

// One NSG serves every session-host subnet. The single deny rule is symmetric:
// associated to all host-pool subnets, traffic from pool A to pool B is denied
// inbound at B's subnet and vice versa. AVD needs no VNet-sourced inbound at
// all (reverse connect: client sessions ride outbound 443 from the session
// host to the AVD gateway), so the rule breaks nothing about the user session.
//
// The source is the VirtualNetwork service tag, deliberately NOT '*':
// - Azure Load Balancer health probes arrive under the AzureLoadBalancer
// tag and continue to match the default AllowAzureLoadBalancerInBound
// rule at priority 65001.
// - Azure platform traffic (IMDS 169.254.169.254, WireServer 168.63.129.16)
// is protected at the fabric layer and unaffected by NSG rules.
//
// Priority 4096 is the lowest custom priority: any future exception (Bastion
// subnet, private endpoint subnet, RDP Shortpath for managed networks) slots
// in above it at 100-4095 without touching this rule.
//
// Do NOT add an outbound mirror rule. Inbound deny on every session-host
// subnet already blocks east-west in both directions, and an outbound
// VirtualNetwork deny would also match flows addressed to the firewall's
// private IP (DNS proxy, explicit-proxy configurations), which sits inside
// the VirtualNetwork tag.
resource nsg 'Microsoft.Network/networkSecurityGroups@2023-09-01' = {
name: nsgName
location: location
properties: {
securityRules: [
{
name: 'DenyVnetEastWest'
properties: {
description: 'Block lateral movement between host-pool user populations. AVD reverse connect requires no VNet-sourced inbound to session hosts.'
protocol: '*'
sourcePortRange: '*'
destinationPortRange: '*'
sourceAddressPrefix: 'VirtualNetwork'
destinationAddressPrefix: '*'
access: 'Deny'
priority: 4096
direction: 'Inbound'
}
}
]
}
}

// Subnet association is intentionally not part of this template. Re-declaring
// the subnets of an existing VNet from a second template risks resetting
// properties the template does not carry (the UDR association from the
// deployment runbook's Step 4, service endpoints, delegations). Associate the
// NSG to each session-host subnet with `az network vnet subnet update` per
// Appendix D.4, or select it in the VNet wizard at creation time. Never
// associate it to AzureFirewallSubnet.
output nsgId string = nsg.id

Subnet association is deliberately left out of the template: re-declaring the subnets of an existing VNet from a second template risks resetting properties the template does not carry, such as the UDR association from the runbook's Step 4. Associate with the CLI or portal as shown next.

Deploy and Associate

Deploy into the shared infrastructure resource group (the NSG is shared infrastructure, like the firewall and the VNet).

Where this runs. Azure Government has no Cloud Shell equivalent in the portal, so use a local terminal or any session host with the Azure CLI installed. Running it from inside the enclave needs no special subnet: see Appendix D.2 § Azure CLI on a session host for the pinned-version install and the Bicep CLI staging step. Who can actually deploy is governed by Azure RBAC, not by the firewall.

Download: load-nsg.azcli

# AVD East-West Segmentation NSG — deploy and associate (GCC High)
#
# Deploys the shared NSG into the AVD infrastructure resource group, then
# associates it with each session-host subnet. Rule rationale, the exception
# checklist, and the validation procedure are in Appendix D.4.
#
# When to run:
# Greenfield — after runbook Step 3 (VNet creation) and before Step 10
# (session hosts), so no host ever exists on an unprotected subnet.
# Retrofit — safe at any time; no allowed flow matches the deny rule. Roll
# out one pool first and validate before associating the rest.
#
# Where to run:
# Azure Government has no Cloud Shell equivalent in the portal, so this runs
# from a local terminal or any session host with the Azure CLI installed.
# Who can actually deploy is governed by Azure RBAC, not by the firewall.
# See Appendix D.2, "Azure CLI on a session host", for the pinned-version
# install. The Bicep CLI downloads itself on first use via the Bicep-CLI
# firewall rule; no staging step is required.
# https://learn.microsoft.com/azure/azure-government/documentation-government-get-started-connect-with-cli
#
# Style: one command per line, no line continuations and no shell loops, so the
# script runs unchanged in PowerShell, bash, and cmd. Substitute the
# <placeholder> values before running.

# 1. Connect to Azure Government.
# az login must precede az account set: setting a subscription requires an
# authenticated context.
az cloud set --name AzureUSGovernment
az login
az account set --subscription <subscription-guid>

# 2. Confirm the active cloud before deploying.
# isActive must read True for AzureUSGovernment.
az cloud list --output table

# 3. Deploy the NSG into the shared infrastructure resource group.
# The NSG is shared infrastructure, like the firewall and the VNet.
az deployment group create --resource-group <rg-name> --template-file avd-nsg.bicep

# 4. Associate the NSG with EACH session-host subnet.
# NEVER associate it with AzureFirewallSubnet.
# One line per pool. Substitute your subnet names from the Multi-Pool Variant
# subnet allocation table in Appendix D.1.
# https://learn.microsoft.com/azure/virtual-network/manage-network-security-group#work-with-network-security-groups
az network vnet subnet update --resource-group <rg-name> --vnet-name <vnet-name> --name <subnet-avd-pool1> --network-security-group nsg-avd-hostpools-eastwest
az network vnet subnet update --resource-group <rg-name> --vnet-name <vnet-name> --name <subnet-avd-pool2> --network-security-group nsg-avd-hostpools-eastwest
az network vnet subnet update --resource-group <rg-name> --vnet-name <vnet-name> --name <subnet-avd-pool3> --network-security-group nsg-avd-hostpools-eastwest

# 5. Validate before rolling out to the remaining pools.
# See Appendix D.4 § Validation for the connectivity tests and the flow-log
# query that confirms the deny rule is matching.

One command per line, no line continuations and no shell loops, so this runs unchanged in PowerShell, bash, and cmd. Substitute your subnet names from the Multi-Pool Variant's subnet allocation table and repeat the association line once per pool (az network vnet subnet update).

Portal equivalent: Network security groups > nsg-avd-hostpools-eastwest > Settings > Subnets > + Associate, once per subnet (manage NSGs).

Greenfield deployments: deploy the NSG immediately after the runbook's Step 3 (VNet creation) and associate all per-pool subnets before any session host exists in Step 10. Retrofits onto a live multi-pool deployment are safe at any time because no allowed flow matches the deny rule, but roll out one pool first per the validation procedure below.

Validation

Associate the NSG to one pilot pool's subnet first, then run all four checks before rolling to the remaining subnets.

1. Cross-pool traffic is denied. From a session host in the pilot pool, probe a session host in a different pool (PowerShell, inside the session host):

Test-NetConnection <other-pool-host-ip> -Port 445 # expect TcpTestSucceeded : False
Test-NetConnection <other-pool-host-ip> -Port 3389 # expect TcpTestSucceeded : False

Before the association both probes connect (or at least reach the TCP layer); after it they time out. Repeat in the reverse direction once a second pool's subnet is associated.

2. The user session still works. Sign in to a desktop in the pilot pool through the Windows App client. The session connects unchanged, because it rides outbound 443, not VNet inbound.

3. The deny rule is the one matching. Azure Network Watcher's IP flow verify against a pilot-pool session host NIC, with a cross-pool source IP, reports access denied and names DenyVnetEastWest as the matched rule. This is the artifact worth screenshotting for the assessment evidence folder.

4. Effective rules show the expected stack. The NIC's effective security rules view shows DenyVnetEastWest at 4096 above the three default inbound rules.

For continuous evidence, enable virtual network flow logs as described in the next section: denied cross-population flows land in the log with rule attribution, timestamped.

Flow Logging to Log Analytics and Sentinel

Point-in-time checks prove the rule works on the day you test it. Flow logging proves it every day, which is what an assessor asks for.

Use VNet flow logs; NSG flow logs can no longer be created

Microsoft stopped supporting new NSG flow log creation on June 30, 2025, and NSG flow logs retire entirely on September 30, 2027, at which point existing flow log resources are deleted (migration guidance). The replacement is virtual network flow logs, which cover all traffic in the VNet (including traffic no NSG processes), need configuring only at the VNet level rather than at both subnet and NIC, and produce no duplicate records.

This changes the KQL too: VNet flow logs with traffic analytics write to the NTANetAnalytics table, which replaces the AzureNetworkAnalytics_CL table used by NSG flow logs. Older guides and sample queries reference the retired table and return "Failed to resolve table or column expression" against a current deployment.

Enable it

Prerequisites: a Log Analytics workspace (reuse law-avd-usgovva from the runbook's Step 5 so flow data lands beside the AVD and Defender telemetry) and a storage account for raw flow records.

  1. Register the Insights provider. Portal search Subscriptions > your subscription > Settings > Resource providers > filter on insight. If Microsoft.Insights shows NotRegistered, select it and choose Register. Flow logging fails silently without it.
  2. Open the blade. Portal search Network Watcher > under Logs, select Flow logs > + Create.
  3. Basics tab. Under Select virtual networks to enable flow logs, choose + Add virtual networks (the blade also offers + Add subnets and + Add network interfaces; see the note below) and select vnet-avd-usgovva. It appears in the grid with a generated Flow Log Name. Under Storage account, set Storage subscription and pick the storage account: it must be in the same region as the VNet but can be in any subscription. If a Retention (days) field appears once a storage account is selected, set 30; 0 retains indefinitely and grows without bound.
  4. Analytics tab. Check Enable traffic analytics, set Traffic analytics processing interval to Every 10 mins (the default is hourly), and select your Log Analytics workspace.
  5. Review + create > Create.

CLI equivalent (az network watcher flow-log create):

az network watcher flow-log create --location usgovvirginia --resource-group rg-avd-shared-usgovva --name fl-vnet-avd-usgovva --vnet vnet-avd-usgovva --storage-account <storage-account-name> --traffic-analytics true --workspace law-avd-usgovva --interval 10

Three things worth knowing:

  • Use the virtual-network scope. Enablement precedence is NIC > subnet > VNet, so a VNet-scoped log covers every pool subnet including ones you add later, and captures traffic that no NSG processes. Per-subnet or per-NIC logs are for narrowing an investigation, not for standing coverage.
  • Traffic analytics is what populates NTANetAnalytics. Skip step 4 and you have blobs in a storage account with nothing to query.
  • Allow one processing interval (10 minutes at the setting above) before the first records appear. The workspace is already a Sentinel workspace if you followed the SIEM chapter, so the table is queryable and available to analytics rules as soon as it fills (Sentinel integration).
  • Two retention settings govern this, and the workspace one matters more. The flow log's Retention (days) covers raw records in the storage account, which traffic analytics consumes and you rarely read directly; 30 days is enough to survive a processing gap or supply unaggregated records to an investigator. The Log Analytics workspace retention governs NTANetAnalytics, which is what your queries and evidence actually run against. Workspaces default to 31 days, but a Sentinel-enabled workspace retains 90 days at no storage charge (Sentinel billing), so set it to 90 at Log Analytics workspace > Usage and estimated costs > Data Retention and stop discarding free evidence.

Queries

Column semantics from the NTANetAnalytics column reference: FlowStatus is Allowed or Denied; FlowDirection is Inbound or Outbound; AclRule carries the NSG rule name that made the decision; SubType == "FlowLog" filters out internal record types.

1. Everything the enclave denied, last 24 hours. The daily triage query:

NTANetAnalytics
| where TimeGenerated > ago(24h)
| where SubType == "FlowLog" and FlowStatus == "Denied"
| summarize DeniedFlows = sum(DeniedInFlows) + sum(DeniedOutFlows)
by SrcIp, DestIp, DestPort, L4Protocol, AclRule, DestVm
| order by DeniedFlows desc

2. What the east-west rule specifically blocked. This is the assessment evidence for SC.L2-3.13.1 and 3.13.6: it demonstrates the internal boundary actively enforcing, not merely configured:

NTANetAnalytics
| where TimeGenerated > ago(7d)
| where SubType == "FlowLog" and FlowStatus == "Denied"
| where AclRule == "DenyVnetEastWest"
| summarize DeniedFlows = sum(DeniedInFlows),
Ports = make_set(DestPort, 20)
by SrcSubnet, DestSubnet, SrcVm, DestVm
| order by DeniedFlows desc

3. Cross-pool lateral movement attempts. Denied inbound flows where source and destination subnets differ, bucketed hourly. A sustained pattern here is a compromised session host probing its neighbors, which is the threat the NSG exists to stop:

NTANetAnalytics
| where TimeGenerated > ago(7d)
| where SubType == "FlowLog" and FlowStatus == "Denied" and FlowDirection == "Inbound"
| where isnotempty(SrcSubnet) and isnotempty(DestSubnet) and SrcSubnet != DestSubnet
| summarize DeniedFlows = sum(DeniedInFlows)
by SrcSubnet, DestSubnet, DestPort, bin(TimeGenerated, 1h)
| order by DeniedFlows desc

Worth promoting to a Sentinel analytics rule once you have a baseline: in a correctly designed enclave the steady-state count is near zero, so any sustained volume is signal rather than noise.

Verify availability in your sovereign cloud

VNet flow logs and traffic analytics availability can lag in Azure Government. Confirm both are present in your region before promising continuous flow evidence in an SSP; if traffic analytics is unavailable, raw flow logs still land in the storage account and can be queried or ingested by other means, but the NTANetAnalytics queries above depend on traffic analytics being enabled.

Adding a Pool Later

A new host pool is one more subnet (Multi-Pool Variant subnet table), one more UDR association (Step 4), one more entry in the firewall's avdSubnetAddressSpace array (Appendix D.2), and one more subnet association to this NSG. No rule changes. The same four validation checks apply, and check the firewall array first: a subnet missing from avdSubnetAddressSpace produces silent egress denies that look like an NSG problem but are not.

Control Mapping

ControlHow this pattern satisfies it
SC.L2-3.13.1 (Boundary Protection)The requirement covers key internal boundaries, not just the perimeter. Per-population subnets with a default-deny NSG at each subnet boundary are the internal-boundary implementation; the Azure Firewall (D.2) is the external one
SC.L2-3.13.6 (Network Communication by Exception)Deny-by-default between user populations, with any permitted east-west flow enumerated as an explicit allow rule above the deny. The assessor-facing narrative: "we denied all VNet-sourced inbound and nothing broke, because the architecture requires no east-west traffic"

Cross-references: architecture context in Scenario: AVD § Network Architecture; subnet allocation and per-pool step categorization in the Multi-Pool Variant; the north-south rule set in Appendix D.2.

📩 Don't Miss the Next Solution

Join the list to see the real-time solutions I'm delivering to my GCC High clients.