Azure Bastion
Native Tunnel
Implementation
How to establish Azure Bastion tunnels entirely in .NET — no Azure CLI, no subprocess, no external dependencies. Discovered by reading the Azure CLI extension source and validated through live testing.
1 Introduction
Azure Bastion provides a managed jump host that allows RDP and SSH connections to Azure VMs — and to any private IP reachable from the Bastion subnet — without exposing public IP addresses. Microsoft provides the Azure CLI command az network bastion tunnel to use this feature from the command line.
This document describes how the same tunnel can be established natively in .NET, without invoking the Azure CLI. The protocol was reverse-engineered by reading the source of the azext_bastion Python extension and validated through live debug capture and testing.
This is not an officially documented API. The /api/tokens endpoint and /webtunnelv2/ WebSocket path are internal implementation details of the Azure Bastion service, exposed for use by the Azure CLI extension. Microsoft may change them without notice. This document reflects the behaviour observed with azext_bastion v1.4.3 in March 2026.
Prerequisites
| Requirement | Value |
|---|---|
| Bastion SKU | Standard or Premium (enableTunneling = true required; Basic SKU does not support tunnelling) |
| IP-connect mode | Additionally requires enableIpConnect = true on the Bastion host |
| RBAC | Reader on the Bastion host + target VM/network minimum; Virtual Machine Contributor to start stopped VMs |
| Authentication | ARM token with management.azure.com/.default scope (standard MSAL) |
2 Undocumented Findings
The following five findings were discovered during implementation. None appear in any public Microsoft documentation. Each caused implementation failures until discovered by reading the CLI source directly.
Body must be form-encoded, not JSON UNDOCUMENTED CRITICAL
The /api/tokens endpoint requires Content-Type: application/x-www-form-urlencoded. Sending application/json returns HTTP 500 "Unexpected internal error" on every attempt, with no indication from the error message that content type is the cause. This was the most persistent issue during implementation and is not documented anywhere.
WebSocket path is /webtunnelv2/ for Standard SKU UNDOCUMENTED
The current CLI extension uses the path /webtunnelv2/{websocketToken}. Older documentation and older CLI versions reference /webtunnel/ (without v2). Using the wrong path causes the WebSocket handshake to fail silently. The v2 path is not documented in any Azure public reference.
Response contains three separate tokens with distinct roles UNDOCUMENTED
The token endpoint returns authToken, websocketToken, and nodeId — three different fields with three different purposes. Only websocketToken goes in the WebSocket URL. authToken is used only for the DELETE cleanup call. Using the wrong token in the WebSocket URL causes the connection to be rejected.
IP-connect mode requires a hostname field in addition to the bh-hostConnect resourceId UNDOCUMENTED
For IP-based connections, the target IP address must appear in both the resourceId path (as /bh-hostConnect/{ipAddress}) and as a separate hostname form field. Omitting the hostname field returns HTTP 404 "Invalid IP address for connection."
The token field is for reconnection — empty string on first call, never omitted UNDOCUMENTED
The POST body must always include token= (an empty string). The field exists for reconnecting an existing session by passing the previous authToken. Omitting it entirely (rather than sending an empty value) may cause issues on some endpoint versions. On first connection it is always an empty string.
3 Protocol Flow
The native tunnel is established in five steps for each client TCP connection. Steps 2–5 repeat for every new RDP client that connects to the local listener port.
Resolve the Bastion DNS name from ARM
Call the Azure Resource Manager API to retrieve the Bastion host's DNS name and validate feature flags.
GET https://management.azure.com/subscriptions/{subscriptionId} /resourceGroups/{resourceGroupName} /providers/Microsoft.Network/bastionHosts/{bastionName} ?api-version=2024-05-01 Authorization: Bearer {ARM token} // Response (excerpt): { "properties": { "dnsName": "bst-<uuid>.bastion.azure.com", "enableTunneling": true, "enableIpConnect": true, "sku": { "name": "Standard" } } }
Validate that enableTunneling is true and SKU is Standard or Premium before proceeding. For IP-connect mode, also validate enableIpConnect.
Obtain a Bastion session token
POST a form-encoded request to the Bastion host's token endpoint. This is not an ARM endpoint — it's on the Bastion DNS host directly.
Must be form-encoded, not JSON. The Content-Type must be application/x-www-form-urlencoded. JSON returns HTTP 500.
POST https://bst-<uuid>.bastion.azure.com/api/tokens HTTP/1.1 Content-Type: application/x-www-form-urlencoded // No Authorization header — ARM token goes in the form body resourceId={resourceId} &protocol=tcptunnel &workloadHostPort=3389 &aztoken={ARM token} &token= // empty on first call; prev authToken for reconnection // IP-connect only — also add: &hostname={targetIpAddress}
The resourceId differs by connection mode:
| Mode | resourceId | Extra fields |
|---|---|---|
| Azure VM | /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{name} | — |
| IP-connect | /subscriptions/{bastionSub}/resourceGroups/{bastionRG}/providers/Microsoft.Network/bh-hostConnect/{ipAddress} | hostname={ipAddress} |
For IP-connect, the resource group in the resourceId is the Bastion's own resource group, not the target machine's. The IP address appears in both the resourceId path and as the separate hostname field.
The response contains three fields:
{
"authToken": "<hex string>", // used for DELETE cleanup only
"websocketToken": "<hex string>", // used in WebSocket URL
"nodeId": "10.x.x.x" // Bastion scale unit identifier
}
Open the WebSocket tunnel
Connect a WebSocket to the Bastion host using the websocketToken (not the authToken).
Use /webtunnelv2/, not /webtunnel/. Older docs and older CLI versions used the v1 path. Standard SKU requires v2. Using the wrong path fails the WebSocket handshake.
WSS wss://bst-<uuid>.bastion.azure.com/webtunnelv2/{websocketToken}?X-Node-Id={nodeId}
In .NET, use ClientWebSocket. No special sub-protocol is required:
var ws = new ClientWebSocket(); ws.Options.SetRequestHeader("User-Agent", "BastionRDPConnector/3.0"); await ws.ConnectAsync(wsUri, cancellationToken);
Bidirectional TCP proxy
Listen on a local TCP port. For each accepted connection, open a new WebSocket (repeating steps 2–3) and proxy bytes in both directions concurrently.
// TCP → WebSocket while (ws.State == WebSocketState.Open) { int n = await tcpStream.ReadAsync(buffer, ct); if (n == 0) break; await ws.SendAsync(buffer[..n], WebSocketMessageType.Binary, endOfMessage: true, ct); } // WebSocket → TCP while (ws.State == WebSocketState.Open) { var result = await ws.ReceiveAsync(buffer, ct); if (result.MessageType == WebSocketMessageType.Close) break; await tcpStream.WriteAsync(buffer[..result.Count], ct); }
Run both directions as concurrent tasks. When either side closes, cancel the other.
Each RDP client connection must get its own independent WebSocket. A WebSocket cannot be shared across multiple clients. Use ArrayPool<byte> for receive buffers to avoid GC pressure during long sessions. The CLI uses a 4096-byte buffer; this is sufficient for RDP traffic.
Session cleanup
After the session ends, delete the Bastion session token using the authToken.
DELETE https://bst-<uuid>.bastion.azure.com/api/tokens/{authToken} HTTP/1.1 X-Node-Id: {nodeId} HTTP/1.1 204 No Content
A 404 response means the session already expired — treat it as success. Cleanup failure is non-critical and should be logged and swallowed, never blocking the next connection.
4 Authentication
Only a single ARM token is needed throughout the entire flow. The token is obtained using the standard MSAL credential with the management.azure.com/.default scope — the same credential used for all other Azure Resource Manager calls in the application.
| Call | Token placement |
|---|---|
| GET /bastionHosts (DNS lookup) | Authorization: Bearer {token} header |
| POST /api/tokens (session token) | aztoken={token} form field — no Authorization header |
| WebSocket connection | No token — websocketToken is embedded in the URL path |
| DELETE /api/tokens (cleanup) | No token — authToken is embedded in the URL path |
Token audience note. The Azure CLI uses management.core.windows.net as the token scope, which produces a token with that audience. A standard MSAL implementation using management.azure.com produces a different audience claim — but the Bastion endpoint accepts both, because Azure AD treats the two URIs as aliases for the same management resource. Significant time was spent during implementation trying to match the CLI scope exactly; this was unnecessary.
5 HTTP Client Configuration
The HttpClient must be configured to use HTTP/1.1. Python's urllib3 (used internally by the CLI) always negotiates HTTP/1.1, and the Bastion endpoint has been observed to behave incorrectly with HTTP/2 POST requests.
var httpClient = new HttpClient(new HttpClientHandler()) { Timeout = TimeSpan.FromSeconds(30), DefaultRequestVersion = new Version(1, 1), DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact };
Build the form body using FormUrlEncodedContent, which automatically sets the correct Content-Type header:
var formFields = new Dictionary<string, string> { ["resourceId"] = resourceId, ["protocol"] = "tcptunnel", ["workloadHostPort"] = targetPort.ToString(), // must be a string ["aztoken"] = armToken, ["token"] = string.Empty // never omit }; // IP-connect only: if (!string.IsNullOrEmpty(targetIpAddress)) formFields["hostname"] = targetIpAddress; request.Content = new FormUrlEncodedContent(formFields);
Set NoDelay = true on accepted TCP connections to minimise RDP latency. Without this, Nagle's algorithm introduces noticeable input lag in interactive sessions.
6 IP-Connect Mode
IP-connect mode allows tunnelling to any private IP address reachable from the Bastion subnet — not just Azure VMs. This includes on-premises hosts connected via ExpressRoute or Site-to-Site VPN, and hosts in other cloud providers connected via VPN.
Three differences from Azure VM mode:
- The
resourceIduses thebh-hostConnectprovider path with the target IP as the resource name - An additional
hostnamefield is required in the POST body, containing the same IP address - The resource group in the
resourceIdis the Bastion's own resource group, not the target's
// Azure VM mode resourceId: /subscriptions/{sub}/resourceGroups/{vmRG}/providers/Microsoft.Compute/virtualMachines/{vmName} // IP-connect mode resourceId: /subscriptions/{bastionSub}/resourceGroups/{bastionRG}/providers/Microsoft.Network/bh-hostConnect/{ipAddress} // IP-connect mode: also add to form body: hostname={ipAddress}
Requires enableIpConnect = true on the Bastion host configuration. This must be enabled in the Azure Portal under the Bastion resource's Configuration blade. It is disabled by default.
7 Error Reference
| Error message | HTTP status | Root cause |
|---|---|---|
| Unexpected internal error | 500 | Wrong Content-Type (JSON instead of form-encoded), or malformed body. This is the most common error during initial implementation. |
| Invalid IP address for connection. | 404 | Missing hostname field in form body for IP-connect mode. |
| Tunneling is disabled | Error | enableTunneling = false on the Bastion host. Must be enabled under Basic → Standard SKU upgrade or via Configuration blade. |
| AuthorizationFailed | 403 | Insufficient RBAC permissions. User needs at least Reader on the Bastion host and target VM/network. |
| WebSocket handshake failed | WS error | Using /webtunnel/ instead of /webtunnelv2/, or using authToken instead of websocketToken in the URL. |
| (no error, sessions hang) | — | HTTP/2 negotiated instead of HTTP/1.1. Force HTTP/1.1 via DefaultVersionPolicy = RequestVersionExact. |
8 Full HTTP Exchanges
Anonymised request/response examples captured during live testing. IP addresses replaced with representative placeholders.
Azure VM mode — POST /api/tokens
POST https://bst-<uuid>.bastion.azure.com/api/tokens HTTP/1.1 Content-Type: application/x-www-form-urlencoded Accept: application/json resourceId=%2Fsubscriptions%2F{sub} %2FresourceGroups%2F{rg} %2Fproviders%2FMicrosoft.Compute %2FvirtualMachines%2F{vmName} &protocol=tcptunnel &workloadHostPort=3389 &aztoken=eyJ0eXAiOi[...] &token=
HTTP/1.1 200 OK Content-Type: application/json { "authToken": "<64-char hex>", "websocketToken": "<hex string>", "nodeId": "10.x.x.x" }
IP-connect mode — POST /api/tokens
POST https://bst-<uuid>.bastion.azure.com/api/tokens HTTP/1.1 Content-Type: application/x-www-form-urlencoded resourceId=%2Fsubscriptions%2F{bastionSub} %2FresourceGroups%2F{bastionRG} %2Fproviders%2FMicrosoft.Network %2Fbh-hostConnect%2F{targetIp} &protocol=tcptunnel &workloadHostPort=3389 &aztoken=eyJ0eXAiOi[...] &token= &hostname={targetIp}
HTTP/1.1 200 OK { "authToken": "<64-char hex>", "websocketToken": "<hex string>", "nodeId": "10.x.x.x" }
WebSocket upgrade
GET /webtunnelv2/{websocketToken} ?X-Node-Id={nodeId} HTTP/1.1 Host: bst-<uuid>.bastion.azure.com Upgrade: websocket Connection: Upgrade User-Agent: BastionRDPConnector/3.0
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Session cleanup — DELETE /api/tokens
DELETE https://bst-<uuid>.bastion.azure.com/api/tokens/{authToken} HTTP/1.1 X-Node-Id: {nodeId} HTTP/1.1 204 No Content
9 Debug Log Output
A successful connection produces the following sequence in the application debug log (%LOCALAPPDATA%\BastionRDPConnector\debug.log):
INFO | === Starting Native Bastion Tunnel (no Azure CLI) === INFO | ARM token: aud=https://management.azure.com, expires=19:15:47 (expires in 3649s) INFO | Bastion SKU: Standard INFO | Bastion features: enableTunneling=True, enableIpConnect=True, enableFileCopy=False INFO | Bastion DNS: bst-<uuid>.bastion.azure.com INFO | TCP listener started on localhost:55000 INFO | Accepted TCP connection from 127.0.0.1:{ephemeral-port} INFO | Token request resourceId: /subscriptions/.../virtualMachines/{vmName} INFO | Token request encoding: application/x-www-form-urlencoded INFO | Token response received (294 chars) INFO | Bastion token obtained (nodeId=10.x.x.x) INFO | WebSocket connected to Bastion (state=Open) INFO | TCP->WS: connection closed normally (...) INFO | Cleaning up Bastion session (authToken length=64) INFO | Bastion session cleaned up successfully
10 Implementation Notes
One WebSocket per TCP client
Each RDP client connection must have its own independent WebSocket. The WebSocket cannot be shared. The TcpListener accept loop should spawn a new task per connection, each independently executing steps 2–5.
Bastion node load balancing
The nodeId returned identifies which Bastion scale unit handled the request. On subsequent connections the nodeId may differ, indicating round-robin load balancing across scale units. Each nodeId must be paired with its corresponding websocketToken — never mix tokens and node IDs from different requests.
Port auto-selection
If the configured local port is already in use, the application increments the port number up to 100 times to find an available one, then falls back to an OS-assigned port. The LocalPortChanged event is fired to notify the UI of the new port.
TCP NoDelay
Set tcpClient.NoDelay = true on all accepted connections to disable Nagle's algorithm. Without this, interactive RDP sessions exhibit noticeable input lag due to packet coalescing.
Buffer sizing
Use a 4096-byte buffer — matching what the CLI uses internally. Use ArrayPool<byte> to rent buffers rather than allocating per-connection, to avoid GC pressure during long sessions.
11 Sources
| Source | Details |
|---|---|
| azext_bastion/tunnel.py | Python source of the Azure CLI bastion extension v1.4.3. Read directly from %USERPROFILE%\.azure\cliextensions\bastion\azext_bastion\tunnel.py on a machine with the extension installed. This is the definitive source for the protocol. |
| Live debug capture | az network bastion tunnel --debug run with mstsc connecting, to capture the exact form body fields, HTTP status codes, and token field names in the _get_auth_token() call. |
| Live validation | Validated against Azure Bastion Standard SKU, West Europe region. Both Azure VM resource ID and on-premises IP over ExpressRoute were tested successfully. |
This document is part of the Azure Bastion RDP Connector project by Timothy van der Ham. The application is available on the Microsoft Store. The native tunnel implementation is contained in NativeBastionTunnel.cs.