Back to blog
AzureBest PracticesCloud SecurityIdentity & AccessNetworking

NSG Allows Public SSH: Closing Port 22 on Azure

Learn why an Azure NSG rule allowing public SSH on port 22 is dangerous, how to fix it with CLI and IaC, and how to prevent it with Azure Policy and CI/CD gates.

TL;DR

This check flags Azure Network Security Group rules that allow inbound SSH (port 22) from the public internet (0.0.0.0/0 or *). Open SSH is a magnet for brute-force bots and credential-stuffing attacks. Lock it down to known IPs or, better, use Azure Bastion and just-in-time access.

Leaving SSH open to the entire internet is one of the most common and most exploited misconfigurations in any cloud environment. On Azure, that exposure usually traces back to a single Network Security Group (NSG) rule that was loosened during testing and never tightened up. The nsg_openssh check exists to catch exactly that.

This post breaks down what the check looks for, why an open port 22 is a real problem and not just a theoretical one, and how to fix and prevent it with CLI, IaC, and policy-as-code.


What this check detects

The nsg_openssh check inspects every inbound security rule across your Network Security Groups and flags any rule that:

  • Has an action of Allow
  • Has a direction of Inbound
  • Covers port 22 (directly, as part of a range, or via *)
  • Has a source of 0.0.0.0/0, *, or the Internet service tag

In other words, it answers a simple question: can anyone on the public internet attempt an SSH connection to your VMs? If the answer is yes, the check fails.

Note: An NSG is Azure's stateful packet filter. It can be attached to a subnet, a network interface, or both. A rule with source * and destination port 22 applies to every resource in that scope, so a single permissive rule can expose dozens of VMs at once.


Why it matters

Port 22 open to the world is not a quiet risk that sits in the background. Automated scanners sweep the entire IPv4 address space continuously, and a freshly exposed SSH endpoint typically starts receiving login attempts within minutes.

Here is what that exposure realistically leads to:

  • Brute-force and password spraying. Bots cycle through common usernames (root, admin, azureuser, ubuntu) and leaked password lists. If password authentication is enabled, it is only a matter of time.
  • Credential reuse attacks. If a key or password leaked elsewhere matches your VM, the attacker is in on the first try.
  • Lateral movement. A compromised VM becomes a pivot point into your private subnets, databases, and managed identities. Azure VMs often carry a managed identity with real permissions attached.
  • Cryptomining and botnet recruitment. The most common outcome of a breached SSH host is a miner chewing through your compute budget, or the host being enrolled into a botnet.

Danger: If the exposed VM has a managed identity with roles like Contributor or access to Key Vault, an SSH compromise can escalate from a single host to your entire subscription. Treat any open-SSH finding on a VM with attached identity as high severity.

The business impact is concrete: surprise compute bills from mining, data exfiltration, ransomware staging, and the incident response hours spent rebuilding hosts you can no longer trust.


How to fix it

The goal is to remove unrestricted internet access to port 22. You have a few options depending on how you actually need to reach the VM.

Step 1: Find the offending rules

List NSGs and inspect their inbound rules for anything allowing port 22 from a wide source:

az network nsg list \
  --query "[].{name:name, rg:resourceGroup}" -o table

# Inspect a specific NSG's rules
az network nsg rule list \
  --resource-group my-rg \
  --nsg-name my-nsg \
  --query "[?destinationPortRange=='22' || contains(destinationPortRanges, '22')]" \
  -o json

Step 2: Restrict the source to known IPs

If you genuinely need direct SSH from a specific office or VPN egress IP, scope the rule to that address instead of the whole internet:

Danger: The command below modifies a live network rule and can immediately cut off existing SSH sessions or automation that relied on the open rule. Confirm your own IP is in the allowed range before applying, or you may lock yourself out.

az network nsg rule update \
  --resource-group my-rg \
  --nsg-name my-nsg \
  --name allow-ssh \
  --source-address-prefixes "203.0.113.10/32" \
  --destination-port-ranges 22 \
  --access Allow

Step 3: Or remove the rule entirely

If nothing should reach port 22 from outside the VNet, delete the rule:

az network nsg rule delete \
  --resource-group my-rg \
  --nsg-name my-nsg \
  --name allow-ssh

Step 4: Prefer Azure Bastion or JIT access

The cleanest fix is to stop exposing port 22 at all. Two Azure-native options:

  • Azure Bastion provides browser-based SSH/RDP over TLS without any public IP on the VM. The NSG never needs an internet-facing port 22.
  • Just-in-Time (JIT) VM access (via Microsoft Defender for Cloud) keeps port 22 closed by default and opens it only for a requesting IP, for a limited time window, then auto-closes it.
# Create a Bastion host (requires an AzureBastionSubnet in the VNet)
az network bastion create \
  --name my-bastion \
  --resource-group my-rg \
  --vnet-name my-vnet \
  --location eastus \
  --public-ip-address my-bastion-pip \
  --sku Standard

Warning: Azure Bastion is billed hourly plus outbound data, and JIT access requires Defender for Cloud's enhanced security plan. Both add cost. For a small set of VMs, scoping NSG rules to a VPN or bastion IP may be more economical. Weigh convenience against spend.

Tip: If you only need occasional administrative access, JIT is usually the best value. Port 22 stays closed the vast majority of the time, which means the attack surface for brute-force bots is effectively zero outside your access windows.


Fixing it in infrastructure as code

If your NSGs are managed by Terraform or Bicep, fix the source there so the change is not reverted on the next deployment.

Terraform

resource "azurerm_network_security_rule" "ssh" {
  name                        = "allow-ssh"
  priority                    = 100
  direction                   = "Inbound"
  access                      = "Allow"
  protocol                    = "Tcp"
  source_port_range           = "*"
  destination_port_range      = "22"
  # Scope to a known prefix, never "*" or "0.0.0.0/0"
  source_address_prefix       = "203.0.113.10/32"
  destination_address_prefix  = "*"
  resource_group_name         = azurerm_resource_group.main.name
  network_security_group_name = azurerm_network_security_group.main.name
}

Bicep

{
  "name": "allow-ssh",
  "properties": {
    "priority": 100,
    "direction": "Inbound",
    "access": "Allow",
    "protocol": "Tcp",
    "sourcePortRange": "*",
    "destinationPortRange": "22",
    "sourceAddressPrefix": "203.0.113.10/32",
    "destinationAddressPrefix": "*"
  }
}

How to prevent it from happening again

Fixing one rule is easy. Making sure the next engineer does not reopen it is the real win. Layer these controls:

1. Azure Policy to deny open SSH

Azure ships a built-in policy definition, "Management ports should be closed on your virtual machines", and you can write a custom deny policy targeting NSG rules with source * on port 22:

{
  "if": {
    "allOf": [
      { "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" },
      { "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", "equals": "Allow" },
      { "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", "equals": "Inbound" },
      { "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", "equals": "22" },
      { "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", "in": ["*", "0.0.0.0/0", "Internet"] }
    ]
  },
  "then": { "effect": "deny" }
}

With this assigned at the subscription or management group level, attempts to create an open-SSH rule are rejected at deployment time.

2. CI/CD gates on IaC

Catch the misconfiguration before it ever reaches Azure. Run a static analysis tool against your Terraform or Bicep in the pipeline:

# Scan Terraform with Checkov in your pipeline
checkov -d ./infra --framework terraform

# Or tfsec
tfsec ./infra

Both flag NSG rules that expose port 22 to the internet. Fail the build on findings so the PR cannot merge.

Tip: Lensix runs nsg_openssh continuously across your subscriptions, so even rules created manually in the portal or through emergency hotfixes get caught after the fact. Pair pipeline scanning (prevention) with continuous checks (detection) for full coverage.

3. Alerting on NSG changes

Send NSG rule modifications to Azure Activity Log and trigger an alert when an inbound rule for port 22 with a broad source is created. That way an accidental change gets a human looking at it within minutes, not on the next quarterly audit.


Best practices

  • No public management ports. Treat 22 and 3389 as never-internet-facing by default. Reach VMs through Bastion, JIT, or a VPN.
  • Disable SSH password auth. Use key-based authentication only. Set PasswordAuthentication no in sshd_config so brute-forcing is pointless even if the port is briefly reachable.
  • Scope to /32 where possible. If you must allow a source IP, use the narrowest prefix. Avoid wide ranges like a full corporate /16 unless you really need it.
  • Use NSG service tags. Reference tags like VirtualNetwork or your own IP groups rather than hardcoding broad CIDRs that drift over time.
  • Audit priorities. A permissive low-priority allow rule can be overridden by a higher-priority deny. Make sure an explicit deny for internet-to-22 sits above any leftover allow rules.
  • Review NSGs on every VNet. Exposure often hides in a subnet-level NSG that nobody remembers attaching.

Open SSH is a solved problem. The tools to close it, Bastion, JIT, scoped rules, Azure Policy, and pipeline scanning, are all readily available. The hard part is consistency, and that is exactly where continuous checks earn their keep.