Back to blog
AzureBest PracticesCloud SecurityIdentity & AccessNetworking

No Azure Bastion Host Provisioned: Why It Matters and How to Fix It

No Azure Bastion host often means VMs are exposed over public RDP/SSH. Learn the risk and how to deploy Bastion, remove public IPs, and prevent recurrence.

TL;DR

This check flags subscriptions with no Azure Bastion host, which usually means VMs are reachable over RDP or SSH from public IPs. Deploy Azure Bastion and remove public 3389/22 exposure to give your team browser-based admin access without opening management ports to the internet.

If your engineers connect to Azure VMs by RDP-ing or SSH-ing straight to a public IP, you have a problem that attackers actively scan for. Exposed management ports are one of the most reliable ways into a cloud environment, and the absence of an Azure Bastion host is a strong signal that this pattern is happening somewhere in your subscription.

This Lensix check, bastion_nohost, looks for subscriptions that have no Bastion provisioned and surfaces them so you can close the gap before someone else finds it.


What this check detects

The bastion_nohost check inspects each Azure subscription and reports when no Azure Bastion host exists. Bastion is a managed jump-host service that lives inside your virtual network and brokers RDP and SSH sessions over TLS through the Azure portal or native clients. When it is missing, administrators typically fall back to one of two patterns:

  • Assigning public IPs directly to VMs and connecting over ports 3389 (RDP) or 22 (SSH).
  • Running a self-managed jump box that may be inconsistently patched and hardened.

The check does not assert that a specific VM is exposed. It flags the structural condition that makes direct public exposure the path of least resistance.

Note: Azure Bastion is deployed per virtual network into a dedicated subnet named AzureBastionSubnet. Once it is in place, you connect to private VMs through the portal with no public IP on the VM itself.


Why it matters

Internet-facing RDP and SSH are among the most heavily targeted attack surfaces in any cloud. Automated bots sweep public IP ranges continuously, and an exposed 3389 or 22 will start receiving login attempts within minutes of going live.

The concrete risks:

  • Brute-force and credential stuffing. Weak or reused passwords on local accounts are a common entry point. Once attackers land on one VM, they pivot laterally across the virtual network.
  • Ransomware delivery. A large share of ransomware incidents trace back to exposed RDP. After gaining a foothold, operators disable backups and encrypt everything they can reach.
  • Exploitation of protocol vulnerabilities. RDP and SSH have a history of serious CVEs such as BlueKeep. An exposed, unpatched host is a sitting target.
  • Compliance exposure. Frameworks like PCI DSS, CIS Azure Foundations, and SOC 2 expect management access to be brokered, logged, and not directly internet-facing.

Danger: An NSG rule allowing 3389 or 22 from Any source is effectively an open invitation. Treat any VM with a public IP and an open management port as an active incident until proven otherwise.


How to fix it

The fix has two halves: deploy a Bastion host, then remove the public RDP/SSH exposure it replaces.

1. Create the Bastion subnet

Bastion requires a subnet named exactly AzureBastionSubnet with a minimum prefix of /26.

az network vnet subnet create \
  --resource-group my-rg \
  --vnet-name my-vnet \
  --name AzureBastionSubnet \
  --address-prefixes 10.0.1.0/26

2. Create a public IP for Bastion

The public IP belongs to the Bastion service, not your VMs. Your VMs stay private.

az network public-ip create \
  --resource-group my-rg \
  --name bastion-pip \
  --sku Standard \
  --location eastus \
  --allocation-method Static

3. Deploy the Bastion host

az network bastion create \
  --name my-bastion \
  --resource-group my-rg \
  --vnet-name my-vnet \
  --public-ip-address bastion-pip \
  --location eastus \
  --sku Standard

Warning: Azure Bastion bills hourly per host plus an outbound data rate, and the Standard SKU costs more than Basic. A single Bastion can serve every peered VNet in a hub-and-spoke topology, so deploy it in the hub rather than once per spoke to control cost.

4. Connect through Bastion

Once provisioned, connect from the portal (VM blade, Connect, Bastion) or via the CLI with native client support enabled on the Standard SKU:

az network bastion ssh \
  --name my-bastion \
  --resource-group my-rg \
  --target-resource-id /subscriptions/SUB_ID/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/my-vm \
  --auth-type ssh-key \
  --username azureuser \
  --ssh-key ~/.ssh/id_rsa

5. Remove the public exposure

This is the step that actually reduces risk. Tighten or delete the NSG rules that allow management ports, and remove public IPs from VMs.

Danger: Before deleting a VM's public IP, confirm no automation, monitoring, or remote access depends on it. Once removed, your only path in is Bastion or VPN, so test connectivity first.

# Delete the inbound RDP rule
az network nsg rule delete \
  --resource-group my-rg \
  --nsg-name my-nsg \
  --name Allow-RDP

# Dissociate and delete the VM's public IP
az network nic ip-config update \
  --resource-group my-rg \
  --nic-name my-vm-nic \
  --name ipconfig1 \
  --remove publicIpAddress

Terraform alternative

If you manage infrastructure as code, define Bastion declaratively so it never drifts out of existence:

resource "azurerm_subnet" "bastion" {
  name                 = "AzureBastionSubnet"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.0.1.0/26"]
}

resource "azurerm_public_ip" "bastion" {
  name                = "bastion-pip"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

resource "azurerm_bastion_host" "main" {
  name                = "my-bastion"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  sku                 = "Standard"

  ip_configuration {
    name                 = "configuration"
    subnet_id            = azurerm_subnet.bastion.id
    public_ip_address_id = azurerm_public_ip.bastion.id
  }
}

How to prevent it from recurring

Deploying Bastion once does not stop a new team from spinning up an exposed VM next quarter. Put guardrails in the path of provisioning.

Azure Policy to deny public IPs on VM NICs

A deny policy stops the most common cause of exposure at the source:

{
  "if": {
    "allOf": [
      {
        "field": "type",
        "equals": "Microsoft.Network/networkInterfaces"
      },
      {
        "field": "Microsoft.Network/networkInterfaces/ipConfigurations[*].publicIpAddress.id",
        "exists": "true"
      }
    ]
  },
  "then": {
    "effect": "deny"
  }
}

Pair it with the built-in policy that flags NSGs permitting RDP or SSH from the internet, and route both to a management group so coverage extends to every new subscription automatically.

Tip: Add a CI gate that runs checkov or tfsec against your Terraform plans. Both ship rules that fail builds when an NSG opens 3389 or 22 to 0.0.0.0/0, catching the mistake before it ever reaches Azure.

Continuous monitoring

Run bastion_nohost in Lensix on a schedule so a deleted or never-provisioned Bastion surfaces quickly. Combine it with periodic NSG audits to confirm nobody re-opened a management port out of band.


Best practices

  • Default to no public IP on VMs. Management access should go through Bastion or a VPN, never a public address bolted onto a workload.
  • Use a hub Bastion. One Bastion in a hub VNet serves peered spokes, which keeps cost and operational overhead down.
  • Prefer the Standard SKU for scale. It supports native client connections, host scaling, and IP-based connections, which matter as your fleet grows.
  • Enforce just-in-time access. Microsoft Defender for Cloud's JIT VM access opens management ports only when requested and for a limited window, complementing Bastion well.
  • Log and review sessions. Send Bastion diagnostic logs to a Log Analytics workspace so you have an audit trail of who connected to what and when.
  • Use Azure AD authentication. Where supported, authenticate to VMs with Azure AD rather than local accounts to centralize identity and revoke access cleanly.

Provisioning Bastion is the easy part. The lasting win comes from making private-by-default the standard for every VM you deploy, so exposed management ports stop being something you have to clean up after the fact.