NSGs, ASGs & no public management
Identity-aware rules, Bastion/JIT, flow logs.
A single rule that allows RDP (Remote Desktop Protocol, port 3389) or SSH (Secure Shell, port 22) from the open internet is found by mass scanners within minutes and brute-forced not long after. That sentence is the whole game. The network layer is where a small mistake turns into an intrusion, and where good defaults quietly cover for you. Identity was the first perimeter. This is the second. Azure gives you two building blocks to draw the walls, plus the services that let you administer a machine no scanner on the internet can even see.
The doorman who remembers you
A Network Security Group (NSG) is a doorman working a numbered guest list. It sits on traffic entering or leaving a subnet or a single NIC (network interface card, the virtual network adapter bolted onto a VM), it reads its rules top to bottom by priority number, and the first rule that matches the packet is the one that decides. Everything below that rule is never read. The doorman is also stateful, which is the part people forget: once he admits a connection, he waves its reply back out on his own. You allow the request in, and the response finds its own way home without a return rule.
The match is a five-part check (source address, source port, destination address, destination port, and protocol), the classic 5-tuple. Your own rules live in priority 100 to 4096, where a lower number is read earlier and therefore wins. Underneath them Azure always appends three immutable default rules: AllowVnetInBound (65000), AllowAzureLoadBalancerInBound (65001), and DenyAllInBound (65500), with mirror-image versions for outbound. That last default is why an NSG is closed to the internet the moment it exists: anything you did not explicitly allow falls through to DenyAllInBound and is dropped. Rules match on plain IPs, on CIDR ranges (Classless Inter-Domain Routing, the 10.0.0.0/16 way of writing a block of addresses), or on service tags, named sets Azure keeps current for you (Internet, VirtualNetwork, AzureLoadBalancer, and regional ones like Storage.EastUS). A tag lets you write intent, from Internet, instead of chasing address ranges that shift under you. One rule can also carry several ports, several prefixes, and several groups at once; Azure calls these augmented security rules, and they are what make --destination-port-ranges 22 3389 and group-to-group rules work at all.
An NSG attaches to a subnet, to a single NIC, or to both at the same time. When both are in play, inbound traffic clears the subnet NSG first and then the NIC NSG; outbound runs the reverse (NIC first, then subnet). A packet has to survive every NSG on its path, so the most restrictive one along the way is the one that counts. This both-must-allow behaviour is the single most common reason a change looks like it did nothing. You opened the subnet, the rule saved cleanly, and a forgotten NSG on the NIC is still dropping the packet three feet later.
ASGs: rules that follow the workload, not the IP
Pinning rules to IP ranges rots the day you scale. New instances get new addresses, ranges drift, and eventually someone widens a block to make an outage go away. An Application Security Group (ASG) fixes that by naming the workload instead of its address. Think of a team badge rather than a desk number: people change desks, the badge still says who they are. An ASG is a named handle you attach to a NIC's IP configuration, and membership follows the instance. You then write rules between ASGs (web-asg reaches app-asg on 443, app-asg reaches db-asg on 5432), and those rules keep meaning what they say as machines come and go. A reviewer reads app to db on 5432 and understands it, instead of decoding a CIDR block.
# ASGs are named handles you attach to NICs. Group by role, not by IP.az network asg create -g rg-net -n web-asg -l eastusaz network asg create -g rg-net -n app-asg -l eastusaz network asg create -g rg-net -n db-asg -l eastus# Attach an ASG to a VM's NIC ip-config. Membership now follows the instance.az network nic ip-config update -g rg-net --nic-name appvm1-nic -n ipconfig1 \--application-security-groups app-asg# App tier -> DB tier on 5432, written between ASGs. No IP addresses to maintain.az network nsg rule create -g rg-net --nsg-name db-nsg -n allow-app-to-db \--priority 100 --direction Inbound --access Allow --protocol Tcp \--destination-port-ranges 5432 \--source-asgs app-asg --destination-asgs db-asg
{"access": "Allow","destinationApplicationSecurityGroups": [{ "id": ".../applicationSecurityGroups/db-asg", "resourceGroup": "rg-net" }],"destinationPortRange": "5432","direction": "Inbound","name": "allow-app-to-db","priority": 100,"protocol": "Tcp","provisioningState": "Succeeded","resourceGroup": "rg-net","sourceApplicationSecurityGroups": [{ "id": ".../applicationSecurityGroups/app-asg", "resourceGroup": "rg-net" }],"sourcePortRange": "*"}
provisioningState: Succeeded tells you the rule is live, and a populated sourceApplicationSecurityGroups confirms Azure resolved the ASG rather than quietly storing nothing. One thing to read carefully: you asked for a single port with --destination-port-ranges 5432, and Azure stored it in the singular destinationPortRange, leaving the plural list empty. Pass two or more ports and it flips to the plural destinationPortRanges instead. Same field, two shapes, and it will bite you the first time you filter output by the wrong one. Two limits are worth planning around as well. ASGs are regional, and both the source and destination ASG named in one rule have to live in the same virtual network (VNet). Reaching across VNets is a peering-plus-firewall problem, not something an ASG stretches to cover.
Deny the management ports, then prove it took
An allow-list is half a control. The other half is an explicit, high-number Deny for the management ports coming from the Internet tag, so no careless allow somewhere else can quietly reopen 22 or 3389, and so the intent is written down where an auditor can read it. Priority is where teams burn an afternoon. A Deny sitting at 4096 will never fire if an Allow at 100 already matched the same traffic, because the first match wins and evaluation stops on the spot. The deny therefore has to sit at a lower number than any allow that could overlap it, or every overlapping allow has to be scoped tightly enough that the traffic you want blocked never matches it in the first place.
# Explicit internet deny for the management ports. A high number is read late, so it# sweeps up leftovers without shadowing the specific allows that sit above it.az network nsg rule create -g rg-net --nsg-name app-nsg -n deny-mgmt-inet \--priority 4096 --direction Inbound --access Deny --protocol Tcp \--destination-port-ranges 22 3389 --source-address-prefixes Internet# Read the rules back. A port lives in destinationPortRange (one value) or# destinationPortRanges (a list), so coalesce the two into a single column.az network nsg rule list -g rg-net --nsg-name app-nsg \--query "[].{Name:name,Prio:priority,Access:access,Dir:direction,Ports:destinationPortRange||join(' ',destinationPortRanges),Src:sourceAddressPrefix}" \-o table
Name Prio Access Dir Ports Src-------------- ---- ------ ------- ------- --------allow-web 100 Allow Inbound 443 Internetdeny-mgmt-inet 4096 Deny Inbound 22 3389 Internet
Reading one NSG on its own will lie to you, because it cannot see the other NSG on the path or the defaults buried underneath. The command that tells the truth flattens the subnet NSG, the NIC NSG, and the built-in defaults into the exact ordered ruleset Azure applies to a given NIC.
# The flattened truth: subnet NSG + NIC NSG + defaults, exactly as Azure applies them.# The VM has to be running; Azure computes this from the live NIC.az network nic list-effective-nsg -g rg-net -n appvm1-nic \--query "value[0].effectiveSecurityRules[?direction=='Inbound'].{Name:name,Prio:priority,Access:access,Port:destinationPortRange}" \-o table
Name Prio Access Port------------------------------ ----- ------ -------UserRule_allow-web 100 Allow 443UserRule_deny-mgmt-inet 4096 Deny 22UserRule_deny-mgmt-inet 4096 Deny 3389DefaultRule_AllowVnetInBound 65000 Allow 0-65535DefaultRule_AllowAzureLoadBala 65001 Allow 0-65535DefaultRule_DenyAllInBound 65500 Deny 0-65535
This is the verification step that ends the argument about why a flow was blocked or, worse, why it was allowed. It names the winning rule, including a default like DenyAllInBound at 65500 or AllowVnetInBound at 65000 when a default is what decided the verdict. Notice two things Azure does here: the multi-port deny expanded into one line per port, and every * normalised to 0-65535. Effective rules always split augmented rules apart like that, which is exactly why reading the raw rule is not enough.
AllowVnetInBound at priority 65000. It permits all traffic between any resources in the virtual network, on any port. Worse, the VirtualNetwork service tag also covers peered VNets and anything reachable over a VPN (virtual private network) or ExpressRoute, so a compromised web VM can open a session to your database on a port no rule ever opened. The default did it. 'Default-closed' describes the internet edge and nothing else. Segmenting tier from tier is work you do on purpose: specific ASG allow-rules at low priority numbers, then one broad Deny from VirtualNetwork to VirtualNetwork at a higher number that still sits below the 65000 default. It is never free.Administer the machine without a public address
Never expose 22 or 3389 to the internet. Two services let you reach a machine that has no public address at all. Azure Bastion is a managed jump host that lives inside your VNet, a guarded internal elevator rather than a door on the street. You connect to it over TLS (Transport Layer Security, the encryption behind https) in the portal, or, on the Standard SKU (stock-keeping unit, Azure's word for a product tier), through a native SSH or RDP client tunnel from your own workstation. Either way the target VM keeps only a private IP. Bastion needs its own dedicated subnet named exactly AzureBastionSubnet, sized /26 or larger, plus a Standard public IP of its own. Native-client tunnelling is the Standard-SKU feature that --enable-tunneling switches on.
Just-in-Time (JIT) VM access takes the other angle. It is part of Microsoft Defender for Cloud (the service formerly named Azure Security Center), and it keeps the management port shut until someone asks. On an approved request it inserts a temporary allow rule scoped to the requester's own source IP, at a lower priority number than the standing deny so it is read first, for a bounded window, then removes that rule automatically when the window runs out. The port is closed the rest of the time, and every opening is recorded with who, which port, from where, and for how long.
# Bastion: browser or native SSH/RDP with no public IP on the target VM.# Standard SKU enables native-client tunnelling; --enable-tunneling turns it on.# (Needs a subnet named AzureBastionSubnet, /26 or larger, plus a Standard public IP.)az network bastion create -g rg-net -n corp-bastion --vnet-name hub-vnet \--public-ip-address bastion-pip --sku Standard --enable-tunneling -l eastus# Open a native SSH session through Bastion. Nothing exposes port 22 to the internet.az network bastion ssh -n corp-bastion -g rg-net --auth-type AAD \--target-resource-id $(az vm show -g rg-app -n appvm1 --query id -o tsv)
Connecting to appvm1 through Azure Bastion...Authenticating with Microsoft Entra ID...Warning: Permanently added 'appvm1' (ED25519) to the list of known hosts.Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-1071-azure x86_64)azureuser@appvm1:~$
# Just-in-Time: request a time-boxed RDP opening via Defender for Cloud. The port# stays shut until this call; the allow is scoped to one source IP and self-expires.SUB=$(az account show --query id -o tsv)VM_ID="/subscriptions/$SUB/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/appvm1"# duration PT1H is ISO-8601 for a one-hour window; the prefix is your own source IP.BODY=$(cat <<JSON{"virtualMachines":[{"id":"$VM_ID","ports":[{"number":3389,"duration":"PT1H","allowedSourceAddressPrefix":"203.0.113.5"}]}],"justification":"patch appvm1"}JSON)az rest --method post \--url "https://management.azure.com/subscriptions/$SUB/resourceGroups/rg-app/providers/Microsoft.Security/locations/eastus/jitNetworkAccessPolicies/default/initiate?api-version=2020-01-01" \--body "$BODY"
{"virtualMachines": [{"id": ".../virtualMachines/appvm1","ports": [{"number": 3389,"allowedSourceAddressPrefix": "203.0.113.5","endTimeUtc": "2026-07-22T15:04:00.000Z","status": "Initiated"}]}]}# A temporary allow rule now exists for one hour, scoped to 203.0.113.5, then it is removed.
The two tools cost and fit differently. Bastion is a fixed hourly charge plus data egress, and it is your everyday path for interactive admin. JIT ships with Defender for Servers and answers the other case, where a tool genuinely needs a real port open for a short, fully audited window. The endTimeUtc in the JIT response is your proof that the opening expires on its own instead of becoming next quarter's forgotten allow rule. And --auth-type AAD on the Bastion session signs in with Microsoft Entra ID (the current name for Azure Active Directory), so there is no local password or SSH key on the box for anyone to steal, only an Entra role such as Virtual Machine Administrator Login.
168.63.129.16, carried by the AzureLoadBalancer service tag, which is the whole reason AllowAzureLoadBalancerInBound exists as a default at 65001. If you add a broad inbound Deny at a low priority number to lock things down, check that it does not also match that probe. Deny the probe and the load balancer decides the VM is dead and pulls it out of rotation, which looks exactly like an application outage while every NSG rule appears correct on inspection.Record every flow before the retirement bites
You cannot investigate a flow you never recorded. Flow logs capture the 5-tuple, the direction, and the allow-or-deny decision for traffic moving through your network. With Traffic Analytics turned on, those records are enriched and shipped to a Log Analytics workspace (the queryable store behind Azure Monitor and Microsoft Sentinel), where you can reconstruct who talked to what during an incident and feed anomaly detection. One date to circle: the classic NSG flow logs are being retired on 30 September 2027, and Azure already blocks creating new ones. Create VNet flow logs instead. A single VNet flow-log resource covers a whole virtual network (or a subnet, or a NIC), so you stop babysitting one log per NSG.
# VNet flow logs -> storage, enriched by Traffic Analytics into the security# operations centre (SOC) workspace. One resource covers the whole VNet;# --interval 10 sets a 10-minute analytics window.az network watcher flow-log create -n hub-vnet-flowlog -l eastus \--resource-group NetworkWatcherRG \--vnet $(az network vnet show -g rg-net -n hub-vnet --query id -o tsv) \--storage-account $(az storage account show -g rg-net -n flowlogsa --query id -o tsv) \--workspace $(az monitor log-analytics workspace show -g rg-obs -n soc-law --query id -o tsv) \--traffic-analytics true --interval 10 --enabled true# Verify it is on and Traffic Analytics is actually wired to the workspace.az network watcher flow-log show -n hub-vnet-flowlog -l eastus \--query "{enabled:enabled, trafficAnalytics:flowAnalyticsConfiguration.networkWatcherFlowAnalyticsConfiguration.enabled, target:targetResourceId}" \-o yaml
enabled: truetrafficAnalytics: truetarget: /subscriptions/.../virtualNetworks/hub-vnet
The show read-back is the habit that keeps this honest. enabled: true with trafficAnalytics: true and the right target means the data is landing in your SOC workspace, not silently switched off after someone edited the VNet last month.
NSGs and ASGs give you fast, cheap segmentation at Layer 3 and Layer 4 (the network and transport layers, meaning addresses and ports) right at the subnet and the NIC. What they cannot do is read application intent. They do not filter outbound by FQDN (fully qualified domain name, a full host name like files.evil.example), and they run no IDPS (Intrusion Detection and Prevention System). Leave the default AllowInternetOutBound in place and a compromised box ships data straight to the internet. Swap it for a DenyAllOutBound and then open one port for updates, and that single allowed port still reaches any address on the planet, files.evil.example included, because the rule reads an address and a port, never a domain name. Controlling and inspecting what leaves your network, by domain name, threat intelligence, and deep inspection, is the job of the next lesson: Azure Firewall and egress control.
Try this
Run az network asg create -g rg-net -n web-asg -l eastus 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: your VNet is wide open east-west by default. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.