Azure Security

Azure Bastion Deep Dive: Native Client Tunneling, Shareable Links, and Just-in-Time Secure Access

Every public IP on a workload VM is an open invitation to the internet’s background noise of credential-stuffing and CVE scanners. The traditional answer was a jump box: one hardened VM with a public IP and RDP/SSH behind an NSG and a VPN — still an internet-facing host you patch, monitor, and explain to your auditor. Azure Bastion removes it. It is a managed, agentless PaaS service that brokers RDP and SSH over TLS, so your VMs need no public IP, no inbound 3389/22 from the internet, and no agent. This guide goes past the portal “Connect” button into the part that matters at scale: native client tunneling, shareable links for third parties, session recording for compliance, hub-and-spoke reuse, and pulling the plug on your legacy jump boxes.

1. Pick the SKU before you touch a subnet

The SKU decides which features exist, and you cannot downgrade later — only upgrade. Choose deliberately; the gaps between the four tiers are large.

Feature Developer Basic Standard Premium
Cost model Free (shared) Hourly + data Hourly + data Hourly + data
Dedicated deployment No (shared) Yes Yes Yes
AzureBastionSubnet required No Yes Yes Yes
VNet peering reach No Yes Yes Yes
Host scaling (instances) No Fixed (2) 2–50 2–50
Native client (tunnel/ssh/rdp) No No Yes Yes
Custom inbound ports No No Yes Yes
File transfer (upload/download) No No Yes Yes
Shareable links No No Yes Yes
IP-based connection No No Yes Yes
Private-only deployment No No No Yes
Session recording No No No Yes

The practical read:

For a centralized, shared Bastion in a hub, deploy Standard at minimum and Premium if you owe anyone a session audit trail.

2. Subnet design, host scaling, and zones

Bastion (every SKU except Developer) requires a dedicated subnet named exactly AzureBastionSubnet. The name is not a convention — the platform keys off it. Two rules people get wrong:

  1. Size it /26 or larger. For any Bastion resource deployed on or after 2 November 2021 the minimum is /26. A /27 will be rejected, and even if you have a grandfathered /27 from before that date, you cannot scale host instances into it. Give it /26 and never think about it again.
  2. It holds nothing else. No NICs, no NAT gateway, no other resource. NSGs are supported (covered in step 7) and route tables are tolerated, but the subnet itself is Bastion’s alone.

Lay the network down with the subnet and a Standard, Static public IP — Bastion will not accept a Dynamic or Basic-SKU IP.

RG=rg-hub-network
LOC=eastus
VNET=vnet-hub
BASTION=bastion-hub

# Dedicated /26 subnet — the name is mandatory
az network vnet subnet create \
  --resource-group "$RG" \
  --vnet-name "$VNET" \
  --name AzureBastionSubnet \
  --address-prefixes 10.0.255.0/26

# Standard SKU, Static allocation — both are required by Bastion
az network public-ip create \
  --resource-group "$RG" \
  --name pip-bastion-hub \
  --sku Standard \
  --allocation-method Static \
  --zone 1 2 3

Host scaling is how Bastion handles concurrency. Each instance (Microsoft calls it a scale unit) is a managed VM behind the service. Basic is fixed at two; Standard and Premium let you set 2 to 50. As a planning rule each scale unit supports roughly 20 concurrent RDP or 40 concurrent SSH sessions, so size to peak concurrency, not VM count. A platform serving 200 simultaneous operators wants ~10 instances — which is why the /26 matters.

Zone redundancy is set at deployment and immutable afterward — you cannot re-zone a live Bastion. In supported regions, pin instances across zones 1, 2, and 3 so a single zone failure does not sever all remote access during an incident, which is precisely when you need it.

az network bastion create \
  --resource-group "$RG" \
  --name "$BASTION" \
  --vnet-name "$VNET" \
  --public-ip-address pip-bastion-hub \
  --sku Standard \
  --scale-units 4 \
  --location "$LOC" \
  --zone 1 2 3 \
  --enable-tunneling true

--enable-tunneling true is the switch that turns on native client support. Without it, the tunnel/ssh/rdp subcommands in the next step fail even on a Standard SKU.

3. Native client tunneling for SSH, RDP, and file transfer

The browser experience is fine for a one-off. For engineers who live in a terminal, want scp, run Ansible, or need an RDP session richer than an HTML5 canvas, native client tunneling is what makes Bastion usable day to day. It requires Standard SKU or higher with tunneling enabled. There are three relevant subcommands, and the distinction matters.

az network bastion ssh opens an interactive SSH session straight to a Linux VM by its resource ID — no public IP, no local port wrangling:

az network bastion ssh \
  --name "$BASTION" \
  --resource-group "$RG" \
  --target-resource-id "/subscriptions/<sub>/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/vm-linux-01" \
  --auth-type AAD

--auth-type accepts AAD (Microsoft Entra login, my default — no SSH keys to manage), ssh-key (with --username and --ssh-key), or password. Entra-based SSH means access is governed by RBAC and Conditional Access instead of a key file that walks out the door on a laptop.

az network bastion tunnel is the workhorse. It opens a raw local TCP tunnel to an arbitrary port on the target that you point any client at — real scp, a database client over the same broker, or a full RDP client:

# Open a local tunnel: localhost:50022 -> VM:22 through Bastion
az network bastion tunnel \
  --name "$BASTION" \
  --resource-group "$RG" \
  --target-resource-id "/subscriptions/<sub>/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/vm-linux-01" \
  --resource-port 22 \
  --port 50022

With that tunnel up, every ordinary tool just works against localhost:50022:

# In a second terminal — standard OpenSSH, standard scp, no Bastion awareness
ssh -p 50022 azureuser@127.0.0.1
scp -P 50022 ./deploy.tar.gz azureuser@127.0.0.1:/tmp/

# RDP example: tunnel 3389, then point mstsc at the local port
az network bastion tunnel -n "$BASTION" -g "$RG" \
  --target-resource-id "<vm-windows-id>" --resource-port 3389 --port 53389
# then: mstsc /v:localhost:53389

For Windows users who want the native RDP experience without managing a tunnel, az network bastion rdp launches mstsc directly:

az network bastion rdp `
  --name $Bastion `
  --resource-group $RG `
  --target-resource-id "<vm-windows-id>"

The tunnel runs only as long as the CLI process lives. For automation, background it and capture the PID so a pipeline step can tear it down deterministically rather than leaking an open broker session.

4. Shareable links and IP-based connections

Two Standard-and-up features cover the awkward access scenarios that NSG rules cannot.

Shareable links generate a URL that lets a user connect to a specific VM via RDP/SSH without an Azure account or portal access. They authenticate against the target VM’s own credentials (local username/password or key), not against Entra. This is the sane answer to “the vendor needs to RDP into the staging box for two days” — far better than cutting a temporary public IP and an NSG hole. Create the link scoped to one VM:

az network bastion create-shareable-link \
  --name "$BASTION" \
  --resource-group "$RG" \
  --vm-id "/subscriptions/<sub>/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/vm-staging-01"

When the engagement ends, revoke it — do not let it rot:

az network bastion delete-shareable-link \
  --name "$BASTION" --resource-group "$RG" \
  --vm-id "<vm-staging-01-id>"

Treat shareable links as time-boxed grants with a calendar reminder or an automation job that deletes them on schedule. A standing shareable link is a standing exposure.

IP-based connections let Bastion reach a target by private IP rather than Azure resource ID. That unlocks non-Azure targets reachable over the same network fabric — on-premises servers across ExpressRoute/VPN, or VMs in a peered VNet — so the same broker serves your hybrid estate. Enable the feature on the host first:

az network bastion update \
  --name "$BASTION" --resource-group "$RG" \
  --enable-ip-connect true

# Then connect to a private IP (e.g. an on-prem host over ExpressRoute)
az network bastion ssh \
  --name "$BASTION" --resource-group "$RG" \
  --target-ip-address 10.50.4.20 \
  --auth-type ssh-key --username opsadmin --ssh-key ~/.ssh/onprem_ed25519

5. Session recording, audit logging, and Just-in-Time

Session recording (Premium only) captures the graphical RDP/SSH session as video. On disconnect, recordings land in a blob container in your storage account via a SAS URL, and you replay them from the Bastion Session Recording blade. This is the artifact auditors ask for in PCI/HIPAA/SOC 2 estates: who connected to which host, when, and what they did on screen. Point it at an immutable, customer-managed-key storage account so the evidence cannot be tampered with after the fact.

For audit logging, every Bastion session emits a diagnostic event. Stream BastionAuditLogs to Log Analytics and you have the connection ledger:

az monitor diagnostic-settings create \
  --name diag-bastion \
  --resource "/subscriptions/<sub>/resourceGroups/$RG/providers/Microsoft.Network/bastionHosts/$BASTION" \
  --logs '[{"category":"BastionAuditLogs","enabled":true}]' \
  --workspace "/subscriptions/<sub>/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-platform"

Now you can ask real questions in KQL — for example, every session in the last day with source IP, target, and protocol:

BastionAuditLogs
| where TimeGenerated > ago(1d)
| extend p = parse_json(Properties)
| project TimeGenerated,
          UserName       = tostring(p.userName),
          ClientIp       = tostring(p.clientIpAddress),
          TargetVm       = tostring(p.targetVMIPAddress),
          Protocol       = tostring(p.protocol),
          Message        = tostring(p.message)
| order by TimeGenerated desc

Just-in-Time (JIT) VM access is complementary, and the pairing is the point. JIT (a Microsoft Defender for Cloud feature) keeps the VM’s management ports closed in the NSG and opens them only for an approved, time-boxed request from a specific source. Because Bastion connects from inside the VNet (its scale units sit in AzureBastionSubnet), your JIT rule grants that subnet rather than an engineer’s roaming public IP — so the port opens just-in-time and only to the broker, never to the internet.

# Request JIT access; the allowed source is the Bastion subnet range, not a public IP
az security jit-policy initiate \
  --resource-group rg-app \
  --location "$LOC" \
  --name default \
  --vm-id "/subscriptions/<sub>/resourceGroups/rg-app/providers/Microsoft.Compute/virtualMachines/vm-linux-01" \
  --ports '[{"number":22,"duration":"PT2H","allowedSourceAddressPrefix":"10.0.255.0/26"}]'

6. Hub-and-spoke reuse with peering and centralized Bastion

You do not deploy a Bastion per VNet — that multiplies cost and operational surface for no benefit. Deploy one Bastion in the hub and let peered spokes ride it. Because Standard/Premium honor VNet peering, a centralized host reaches VMs in every connected spoke.

Two requirements make this work:

  1. Peering on both sides with --allow-forwarded-traffic true, so brokered traffic transits the hub.
  2. The spoke VMs are reachable from the hub’s AzureBastionSubnet — i.e. no NSG between hub and spoke drops the brokered RDP/SSH.
# Hub <-> spoke peering, both directions, forwarded traffic allowed
az network vnet peering create \
  --name hub-to-spoke-app \
  --resource-group rg-hub-network \
  --vnet-name vnet-hub \
  --remote-vnet "/subscriptions/<sub>/resourceGroups/rg-spoke-app/providers/Microsoft.Network/virtualNetworks/vnet-spoke-app" \
  --allow-vnet-access true \
  --allow-forwarded-traffic true

az network vnet peering create \
  --name spoke-app-to-hub \
  --resource-group rg-spoke-app \
  --vnet-name vnet-spoke-app \
  --remote-vnet "/subscriptions/<sub>/resourceGroups/rg-hub-network/providers/Microsoft.Network/virtualNetworks/vnet-hub" \
  --allow-vnet-access true \
  --allow-forwarded-traffic true

One caveat worth flagging: Bastion does not traverse a second hop. If a spoke is peered to the hub but the actual VM lives in a VNet peered only to that spoke (peering is non-transitive), Bastion will not reach it. Connect spokes to the hub directly, or for the genuinely meshed case, terminate access at a hub Bastion with the spokes peered to that hub.

7. Hardening: NSGs, Conditional Access, and RBAC

Bastion shrinks the attack surface, but it is not a free pass. Three layers.

NSG on AzureBastionSubnet. Bastion requires a specific set of flows, and an over-zealous NSG will silently break it. The mandatory shape: allow inbound 443 from Internet (the control plane and the HTTPS client), inbound 443 and 4443 from GatewayManager, and on the egress side allow outbound 3389/22 to VirtualNetwork (to reach target VMs) and 443 to AzureCloud.

az network nsg rule create -g "$RG" --nsg-name nsg-bastion \
  --name Allow-HTTPS-Inbound --priority 120 --direction Inbound --access Allow \
  --protocol Tcp --source-address-prefixes Internet \
  --destination-port-ranges 443 --destination-address-prefixes '*'

az network nsg rule create -g "$RG" --nsg-name nsg-bastion \
  --name Allow-GatewayManager-Inbound --priority 130 --direction Inbound --access Allow \
  --protocol Tcp --source-address-prefixes GatewayManager \
  --destination-port-ranges 443 4443 --destination-address-prefixes '*'

az network nsg rule create -g "$RG" --nsg-name nsg-bastion \
  --name Allow-SshRdp-Outbound --priority 100 --direction Outbound --access Allow \
  --protocol Tcp --source-address-prefixes '*' \
  --destination-port-ranges 22 3389 --destination-address-prefixes VirtualNetwork

Conditional Access. Native client and Entra-based SSH authenticate through Microsoft Entra ID, which means Conditional Access applies. Require MFA and a compliant device on the Azure management surface and you have gated every native Bastion session behind your phishing-resistant posture — without touching a single VM.

RBAC. The ability to connect is an RBAC outcome. A user needs Reader on the Bastion, Reader on the VM, and the relevant data-plane action on the NIC. Scope Reader plus a custom connect role at the resource-group level; do not hand out Virtual Machine Administrator Login where Virtual Machine User Login will do. Grant the login role through PIM so even the connect right is itself just-in-time.

8. Cost optimization and decommissioning the jump boxes

Bastion bills an hourly host rate plus scale-unit and data charges (the first 5 GB/month of outbound is free). The wins:

Decommission a legacy jump box methodically: cut over users to Bastion, confirm BastionAuditLogs shows them connecting, then dissociate and delete the public IP last so you can roll back if something was missed.

# Strip the public IP off a VM NIC once Bastion access is proven
az network nic ip-config update \
  --resource-group rg-app --nic-name nic-jumpbox-01 \
  --name ipconfig1 --remove publicIpAddress

az network public-ip delete -g rg-app --name pip-jumpbox-01

Enterprise scenario

A payments platform team I worked with ran a hub-and-spoke estate across three regions with roughly 400 VMs, and PCI-DSS forced two hard constraints: no workload VM may carry a public IP, and every interactive admin session must be recorded and retained. Their interim state was four per-region jump boxes — internet-facing, RDP open behind NSGs, and the QSA flagged them as in-scope cardholder-data-environment ingress with no session evidence.

We collapsed all four jump boxes into a single Premium Bastion in each regional hub (Premium for the session recording requirement) with --scale-units 8 to cover ~120 concurrent operators per region during a release window. Spokes were already peered to the hub with forwarded traffic allowed, so no new networking was needed beyond confirming the peering flags. Session recordings were written to a storage account with an immutability policy and customer-managed keys, satisfying the tamper-evidence requirement, and BastionAuditLogs streamed to a central Log Analytics workspace gave the QSA the connection ledger they wanted.

The sharp edge was the QSA also requiring that admin ports stay closed except during approved access — recording alone was not enough. We wired Defender for Cloud JIT so the NSG kept 22/3389 shut, and the JIT grant opened them only to the hub’s AzureBastionSubnet range, never to a public source. Because Bastion brokers from inside the VNet, the source prefix on the JIT rule was the subnet, not an engineer’s roaming IP:

az security jit-policy initiate \
  --resource-group rg-spoke-payments --location eastus --name default \
  --vm-id "/subscriptions/<sub>/resourceGroups/rg-spoke-payments/providers/Microsoft.Compute/virtualMachines/vm-pay-07" \
  --ports '[{"number":3389,"duration":"PT1H","allowedSourceAddressPrefix":"10.0.255.0/26"}]'

The net result: zero public IPs on workloads, ports closed by default and opened just-in-time to the broker subnet alone, full session video retained immutably, and four internet-facing jump boxes per region deleted. The CDE ingress finding closed, and the standing public-IP cost went with it.

Verify

Confirm the deployment actually does what you think before you trust it:

# 1. Bastion is provisioned, Standard/Premium, tunneling on
az network bastion show -g "$RG" -n "$BASTION" \
  --query "{sku:sku.name, tunneling:enableTunneling, scaleUnits:scaleUnits, zones:zones}" -o table

# 2. The subnet is exactly AzureBastionSubnet and /26 or larger
az network vnet subnet show -g "$RG" --vnet-name "$VNET" \
  -n AzureBastionSubnet --query "{name:name, prefix:addressPrefix}" -o table

# 3. Public IP is Standard + Static
az network public-ip show -g "$RG" -n pip-bastion-hub \
  --query "{sku:sku.name, alloc:publicIPAllocationMethod}" -o table

# 4. A native SSH session actually lands (Entra auth)
az network bastion ssh -n "$BASTION" -g "$RG" \
  --target-resource-id "<vm-linux-id>" --auth-type AAD

# 5. The audit ledger is populating (run after at least one session)
#    -> query BastionAuditLogs in Log Analytics; expect your connection rows

If step 4 hangs or refuses, the usual suspects are: tunneling not enabled, an NSG on AzureBastionSubnet blocking the required 443/4443 inbound or 22/3389 outbound, missing RBAC on the VM/NIC, or a non-transitive peering hop between the hub and the target VM.

Checklist

AzureBastionSecurityRemote AccessNetworking

Comments

Keep Reading