Authentication in Air-Gapped Clouds
This is the core challenge of sovereign cloud development: the Azure Identity SDKs assume they can reach Microsoft’s public internet to validate your login authority. In air-gapped clouds, that assumption breaks everything.
This page explains why, shows how the reference app solves it, and provides working code in all four Azure SDK languages.
Instance Discovery — Why It Breaks
When you create a credential with @azure/identity (or the Python, .NET, Go equivalents), MSAL performs instance discovery — a network call to validate your authority host against Microsoft’s public directory.
Your app ──► MSAL ──► GET https://login.microsoft.com/common/discovery/instance
?authorization_endpoint=https://{your-authority}/...
This works for three well-known authority hosts:
| Authority Host | Cloud |
|---|---|
login.microsoftonline.com | Azure Public |
login.microsoftonline.us | Azure Government |
login.chinacloudapi.cn | Azure China |
For every other authority host — Azure Stack Hub, Azure Government Secret, Azure Government Top Secret, or any custom cloud — this call fails because:
- The authority host (e.g.,
login.mystack.contoso.local) is not registered with Microsoft’s public directory - The network cannot reach
login.microsoft.comto perform the validation - Even if it could, Microsoft’s endpoint has no knowledge of enclave or on-premises authorities
The result: a cryptic MSAL error before your app ever attempts to authenticate.
The fix: Set
disableInstanceDiscovery: trueon every credential constructor. This tells MSAL to skip the public validation and trust the authority host you provided.
Per-Cloud Requirements
| Cloud | Authority Host | disableInstanceDiscovery | Why |
|---|---|---|---|
| Azure Public | login.microsoftonline.com | false (default) | Well-known host; discovery succeeds |
| Azure Government | login.microsoftonline.us | false (default) | Well-known host; discovery succeeds |
| Azure China | login.chinacloudapi.cn | false (default) | Well-known host; discovery succeeds |
| Azure Stack Hub | Custom (on-prem) | true | Not in public directory; network may be isolated |
| Azure Gov Secret (IL6) | Enclave-specific | true | Air-gapped; no public internet access |
| Azure Gov Top Secret (TS/SCI) | Enclave-specific | true | Air-gapped; no public internet access |
| Custom Cloud | User-provided | true | Unknown to public directory |
Auto-detection logic
The reference app determines this automatically. If the authority host is one of the three well-known AzureAuthorityHosts values, instance discovery stays enabled. For everything else, it’s disabled:
// src/auth/credentialFactory.ts
import { AzureAuthorityHosts } from "@azure/identity";
export function shouldDisableInstanceDiscovery(authorityHost: string): boolean {
const wellKnownHosts = new Set([
AzureAuthorityHosts.AzurePublicCloud, // "https://login.microsoftonline.com"
AzureAuthorityHosts.AzureGovernment, // "https://login.microsoftonline.us"
AzureAuthorityHosts.AzureChina, // "https://login.chinacloudapi.cn"
]);
const normalized = authorityHost.replace(/\/+$/, "");
return !wellKnownHosts.has(normalized as AzureAuthorityHosts);
}
You don’t need to configure this manually. The reference app reads the
authorityHostfrom your cloud profile and auto-detects whether to disable instance discovery.
How the Reference App Handles It
The createCredentialPlan() function in src/auth/credentialFactory.ts applies the auto-detection to every credential that accepts the option:
case "clientSecret": {
const credential = new ClientSecretCredential(
requireEnv("AZURE_TENANT_ID", env),
requireEnv("AZURE_CLIENT_ID", env),
requireEnv("AZURE_CLIENT_SECRET", env),
{
authorityHost: profile.authorityHost,
disableInstanceDiscovery: shouldDisableInstanceDiscovery(profile.authorityHost),
},
);
// ...
}
The same pattern applies to WorkloadIdentityCredential and DeviceCodeCredential. Each receives authorityHost from the cloud profile and the computed disableInstanceDiscovery value.
Two credential types are exempt:
ManagedIdentityCredential— Uses IMDS or platform-specific token endpoints, never contacts an authority host directlyAzureCliCredential— Delegates authority handling to the Azure CLI, which manages its own cloud configuration viaaz cloud set
Code Examples — All Four Languages
The following examples use Azure Stack Hub placeholder URLs. Replace them with your actual cloud endpoints — the pattern is identical for Azure Government Secret, Top Secret, or any custom cloud.
TypeScript
import { ClientSecretCredential } from "@azure/identity";
const credential = new ClientSecretCredential(tenantId, clientId, secret, {
authorityHost: "https://login.mystack.contoso.local/",
disableInstanceDiscovery: true,
});
// Acquire a token for the ARM audience (with /.default scope suffix)
const token = await credential.getToken(
"https://management.mystack.contoso.local/.default"
);
Python
from azure.identity import ClientSecretCredential
credential = ClientSecretCredential(
tenant_id=tenant_id,
client_id=client_id,
client_secret=secret,
authority="https://login.mystack.contoso.local/",
disable_instance_discovery=True,
)
token = credential.get_token(
"https://management.mystack.contoso.local/.default"
)
In the Python SDK, the parameter is
authority(notauthority_host) anddisable_instance_discoveryuses snake_case. Internally, MSAL Python stores this asinstance_discovery = not disable_instance_discovery.
C# (.NET)
using Azure.Identity;
var options = new ClientSecretCredentialOptions
{
AuthorityHost = new Uri("https://login.mystack.contoso.local/"),
DisableInstanceDiscovery = true,
};
var credential = new ClientSecretCredential(tenantId, clientId, secret, options);
var context = new TokenRequestContext(
new[] { "https://management.mystack.contoso.local/.default" }
);
var token = await credential.GetTokenAsync(context);
Go
import (
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
)
opts := azidentity.ClientSecretCredentialOptions{}
opts.Cloud = cloud.Configuration{
ActiveDirectoryAuthorityHost: "https://login.mystack.contoso.local/",
}
opts.DisableInstanceDiscovery = true
cred, err := azidentity.NewClientSecretCredential(
tenantID, clientID, secret, &opts,
)
// Use the ARM audience as the scope
token, err := cred.GetToken(context.TODO(), policy.TokenRequestOptions{
Scopes: []string{"https://management.mystack.contoso.local/.default"},
})
The Go SDK uses
cloud.Configurationto set the authority host — theCloudfield on credential options. This is the samecloudpackage used by the Azure SDK service clients for endpoint configuration.
Credential Type Matrix
Not all credential types interact with authority hosts the same way. Use this matrix to understand what’s needed for each:
| Credential Type | authorityHost | disableInstanceDiscovery | Air-Gap Notes |
|---|---|---|---|
ClientSecretCredential | ✅ | ✅ | Full support. Set both for custom clouds. |
ClientCertificateCredential | ✅ | ✅ | Same pattern as ClientSecret. |
WorkloadIdentityCredential | ✅ | ✅ | Used in Kubernetes with federated tokens. |
DeviceCodeCredential | ✅ | ✅ | Interactive. User must access the custom authority URL. |
UsernamePasswordCredential | ✅ | ✅ | Supported but discouraged for production. |
DefaultAzureCredential | ✅ | ✅ | Chains multiple credentials; applies the flag to the chain. |
ManagedIdentityCredential | — | — | Uses IMDS (169.254.169.254) or platform token endpoints. No authority host needed. |
AzureCliCredential | — | — | Handled by az cloud set. CLI manages authority internally. |
ManagedIdentityCredentialworks in air-gapped clouds without any special configuration — it contacts the local IMDS endpoint or the platform’s managed identity service, not an external authority. The scope/resource parameter still must match the enclave’s ARM audience.
ADFS Support on Azure Stack Hub
Azure Stack Hub deployments can use either Microsoft Entra ID or Active Directory Federation Services (ADFS) as their identity provider.
When ADFS is the identity provider, set tenantId to the literal string "adfs". The Azure Identity SDK treats this as a special case and implicitly disables instance discovery:
// From the SDK source (msal/utils.ts — getKnownAuthorities):
if ((tenantId === "adfs" && authorityHost) || disableInstanceDiscovery) {
return [authorityHost]; // Skip instance discovery
}
return [];
This means that with ADFS, you can omit disableInstanceDiscovery: true — the SDK infers it from the tenant ID:
const credential = new ClientSecretCredential(
"adfs", // tenantId — triggers implicit disable
clientId,
secret,
{
authorityHost: "https://adfs.mystack.contoso.local/adfs",
},
);
For clarity and forward compatibility, the reference app still sets
disableInstanceDiscovery: trueexplicitly even whentenantIdis"adfs". Explicit is better than implicit.
Azure Government Secret and Top Secret enclaves always use Entra ID (not ADFS). The
"adfs"tenant ID shortcut applies only to Azure Stack Hub deployments.
Dynamic Audience Discovery
Hardcoding ARM audiences works, but Azure Stack Hub (and other custom clouds) expose a metadata endpoint that returns the correct audience at runtime. This is the recommended approach for production deployments where audiences may change.
The metadata request
GET https://management.mystack.contoso.local/metadata/endpoints?api-version=2015-01-01
Response shape
{
"galleryEndpoint": "https://adminportal.mystack.contoso.local:30015/",
"graphEndpoint": "https://graph.windows.net/",
"authentication": {
"loginEndpoint": "https://login.mystack.contoso.local/",
"audiences": [
"https://management.mystack.contoso.local/"
]
},
"portalEndpoint": "https://portal.mystack.contoso.local/"
}
Using the discovered audience
The authentication.audiences[0] value is your ARM audience. Append /.default to form the scope:
async function discoverArmAudience(armEndpoint: string): Promise<string> {
const metadataUrl = new URL(
"/metadata/endpoints?api-version=2015-01-01",
armEndpoint,
);
const response = await fetch(metadataUrl);
const metadata = await response.json();
// Returns e.g. "https://management.mystack.contoso.local/"
return metadata.authentication.audiences[0];
}
// Usage:
const audience = await discoverArmAudience(profile.resourceManagerEndpoint);
const token = await credential.getToken(`${audience}.default`);
The metadata endpoint does not require authentication — it’s a public (within the network) discovery endpoint. This same pattern works on Azure Government Secret and Top Secret enclaves at their respective ARM endpoints.
The reference app stores the ARM audience in the cloud profile’s
resourceManagerAudiencefield. For Azure Stack deployments, you can populate this dynamically from the metadata endpoint during startup, or set it statically in the profile JSON.
Azure CLI Cloud Registration
When using AzureCliCredential, the Azure CLI must know about your custom cloud. Register it before running az login:
# Register your custom cloud
az cloud register -n MyStack \
--endpoint-resource-manager "https://management.mystack.contoso.local/" \
--suffix-storage-endpoint ".mystack.contoso.local" \
--suffix-keyvault-dns ".vault.mystack.contoso.local"
# Set it as the active cloud
az cloud set --name MyStack
# Login against the custom cloud
az login
After registration, AzureCliCredential uses the CLI’s cloud configuration automatically — no authorityHost or disableInstanceDiscovery needed in your application code:
import { AzureCliCredential } from "@azure/identity";
// CLI already knows the cloud — no special options needed
const credential = new AzureCliCredential({
tenantId: process.env.AZURE_TENANT_ID,
});
Pre-registered clouds
The Azure CLI includes several clouds out of the box:
| CLI Cloud Name | Environment |
|---|---|
AzureCloud | Azure Public |
AzureUSGovernment | Azure Government |
AzureChinaCloud | Azure China |
For Azure Government, you only need az cloud set --name AzureUSGovernment — no registration required.
For Azure Stack Hub, Government Secret, and Top Secret — the cloud must be explicitly registered with
az cloud registerusing the correct endpoints for your environment.
Listing and verifying cloud registrations
# List all registered clouds
az cloud list --output table
# Show the active cloud's endpoints
az cloud show --output json
SDK Internals — How It Works Under the Hood
Understanding the internal flow helps debug authentication issues in air-gapped environments:
┌──────────────────────────────────────────────────────────────────┐
│ Application Code │
│ credential = new ClientSecretCredential(tenantId, clientId, │
│ secret, { authorityHost, disableInstanceDiscovery: true }) │
└────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ @azure/identity │
│ │
│ 1. getAuthorityHost(options) │
│ → options.authorityHost ?? AZURE_AUTHORITY_HOST env var │
│ → fallback: "https://login.microsoftonline.com" │
│ │
│ 2. getKnownAuthorities(tenantId, authority, disableDiscovery) │
│ → if disableDiscovery: return [authorityHost] │
│ → else: return [] (MSAL validates against public endpoint) │
│ │
│ 3. createMsalClient() │
│ → MSAL config: auth.knownAuthorities = [authorityHost] │
│ → Skips instance discovery, trusts the provided authority │
└────────────────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ MSAL (@azure/msal-node) │
│ │
│ POST {authorityHost}/{tenantId}/oauth2/v2.0/token │
│ Body: client_id, client_secret, scope={audience}/.default │
│ │
│ Returns: { access_token, expires_in, ... } │
└──────────────────────────────────────────────────────────────────┘
When disableInstanceDiscovery is true, MSAL adds your authority host to its knownAuthorities list. This tells MSAL to trust it without validation — the token request goes directly to {authorityHost}/{tenantId}/oauth2/v2.0/token.
Troubleshooting
“Authority host validation failed”
Cause: Instance discovery is enabled (default) but your authority host isn’t one of the three well-known hosts.
Fix: Set disableInstanceDiscovery: true in your credential options.
“Network request failed” during credential creation
Cause: MSAL is trying to reach login.microsoft.com for instance discovery, but the network is air-gapped.
Fix: Set disableInstanceDiscovery: true. The error occurs before any token request because instance discovery happens during the first getToken() call.
Token acquired but API calls return 401
Cause: The token audience doesn’t match what the target service expects. This often happens when the ARM audience is hardcoded to the public cloud value.
Fix: Use the audience from your cloud profile’s resourceManagerAudience or discover it dynamically from the metadata endpoint. Ensure you append /.default to form the scope.
“AADSTS50049: Unknown or invalid instance”
Cause: Your authority host URL is malformed or the Entra ID / ADFS endpoint isn’t responding at that address.
Fix: Verify the authority host URL is correct and reachable from your network. Check for trailing slashes — the SDK normalizes them, but some proxies don’t.
When debugging authentication in air-gapped environments, enable SDK logging by setting
AZURE_LOG_LEVEL=verbose. This surfaces the exact URLs MSAL is trying to reach.