Nitin Kumar SinghSolutions Architect

Type to search. ↑↓ to move, Enter to open.

    ↑↓ move ↵ open esc close
    Deep DiveAzurePart 2 of 2

    The Keyless Cookbook: Managed Identity for Every Azure Service Your Agent Touches

    The exact RBAC role, disable-local-auth switch, and DefaultAzureCredential snippet for every Azure hop an agent uses — OpenAI, AI Search, Cosmos, SQL.

    This is Part 2 of a two-part series. Part 1 made the governance case: one identity per workload, least privilege by default, and keyless enforced by policy. This is the build. First the credential model in production, then the per-resource cookbook.

    Code is C# with Azure.Identity, but the identity model is identical across the SDK languages1; translate freely.

    The credential model in production

    The whole model rests on one type: DefaultAzureCredential. It is a chained credential that tries a fixed sequence and stops at the first one that produces a token: environment variables, then workload identity (federated Kubernetes), then managed identity, then developer tools (Visual Studio, VS Code, the Azure CLI, Azure PowerShell, the Azure Developer CLI).

    In Azure it resolves to the workload’s managed identity; on a laptop it resolves to the developer’s own az login identity, so there is never a shared string checked into a repo.

    using Azure.Identity;
    using Azure.AI.OpenAI;
    var client = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential());

    Pin the credential in production. Shipping DefaultAzureCredential as-is is the mistake most teams make. The chain is a convenience for local development; in production it is a liability, for four reasons Microsoft’s own guidance is explicit about:

    • Hard to debug which credential actually answered.
    • Slower: the sequential probing adds latency on every token request.
    • The real trap: it reads environment variables any host user can set, so it can silently fall back to a developer’s CLI identity on a prod VM (“unexpected elevation or reduction of privileges”).
    • No retries. Reached through the chain, ManagedIdentityCredential runs in “fail fast” mode. Pinned, it switches to resilient mode: exponential backoff, up to five retries.
    // Production: deterministic, resilient, no probing, no silent fallback.
    TokenCredential credential = new ManagedIdentityCredential(
    ManagedIdentityId.FromUserAssignedClientId(userAssignedClientId));

    A lighter-touch option keeps DefaultAzureCredential but makes it deterministic: set AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential. As of the October 2025 Azure SDK release that variable skips the IMDS probe and goes straight to token acquisition with full retries (needs Azure.Identity 4.11 or the equivalent across languages); .NET Aspire 13.0 sets it automatically for Container Apps and App Service.

    Create the user-assigned identity, attach it to compute. Part 1 made the governance case for user-assigned; here is the wiring. Create it once, then attach it to the compute that makes the calls. The flag name differs by service:

    Terminal window
    # Create once — its roles can exist before the compute does.
    az identity create --resource-group rg-agents --name agent-identity
    MI_CLIENT_ID=$(az identity show -g rg-agents -n agent-identity --query clientId -o tsv)
    MI_PRINCIPAL_ID=$(az identity show -g rg-agents -n agent-identity --query principalId -o tsv)
    MI_RESOURCE_ID=$(az identity show -g rg-agents -n agent-identity --query id -o tsv)
    # Attach it — Container Apps uses --user-assigned, App Service uses --identities.
    az containerapp identity assign -g rg-agents -n my-agent --user-assigned "$MI_RESOURCE_ID"
    az webapp identity assign -g rg-agents -n my-agent --identities "$MI_RESOURCE_ID"

    $MI_CLIENT_ID is the value you hand to the pinned ManagedIdentityCredential; $MI_PRINCIPAL_ID is what you grant roles to in every hop below.

    On Kubernetes, federate instead. AKS exposes an OIDC issuer; you create a federated credential on the user-assigned identity that trusts tokens from that issuer for a specific service account. The pod’s short-lived, Kubernetes-issued service-account token is exchanged directly for an Entra token, and no client secret or certificate is ever stored.

    sequenceDiagram
    participant Pod as Agent pod (K8s service account)
    participant AKS as AKS OIDC issuer
    participant Entra as Microsoft Entra ID
    participant Res as Azure resource
    Pod->>AKS: read projected service-account token
    Pod->>Entra: exchange token via federated credential
    Entra-->>Pod: short-lived Entra access token
    Pod->>Res: call with bearer token
    Res-->>Pod: data, authorized by RBAC
    Note over Pod,Res: no client secret stored anywhere

    The federated token exchange: a Kubernetes-issued token in, an Entra token out, no secret at rest.

    Terminal window
    az identity federated-credential create \
    --name agent-federated-cred --identity-name agent-identity \
    --resource-group rg-agents --issuer "${AKS_OIDC_ISSUER}" \
    --subject system:serviceaccount:agents:agent-sa \
    --audience api://AzureADTokenExchange

    Pods opt in with the label azure.workload.identity/use: "true", and AKS treats it as fail-closed: an unlabeled pod gets no identity rather than falling back to something broader. The older AAD Pod Identity approach is deprecated; Workload Identity is the replacement.

    One pattern, every hop

    With the identity created and pinned, every resource follows the same three steps:

    1. Assign the data-plane role to the workload’s managed identity (Azure RBAC, or for the SQL and PostgreSQL databases a database-level role, which is a different mechanism worth calling out).
    2. Disable local or shared-key auth on the resource so the key stops being a valid path.
    3. Connect with DefaultAzureCredential (or a pinned ManagedIdentityCredential), with no key in the connection.

    A recurring theme across every service: the Azure management roles (Owner, Contributor) almost never grant data access. You have to assign the data-plane role explicitly.

    Step one is the same command everywhere: grant the workload’s managed identity a role, scoped to a single resource:

    Terminal window
    az role assignment create \
    --assignee-object-id "$MI_PRINCIPAL_ID" \
    --assignee-principal-type ServicePrincipal \
    --role "Cognitive Services OpenAI User" \
    --scope "$(az cognitiveservices account show -g rg-agents -n my-aoai --query id -o tsv)"

    Two production details the docs stress: pass --assignee-object-id with --assignee-principal-type ServicePrincipal rather than --assignee <name>, so the CLI skips a Microsoft Graph name lookup that fails on a freshly created identity from replication lag; and expect about five minutes of propagation before the first call works.

    In Bicep you reference roles by GUID: Cognitive Services OpenAI User is 5e0bd9bd-7b93-4f28-af87-19fc36ad61bd, Storage Blob Data Contributor is ba92f5b4-2d11-453d-a403-e96b0029c9fe:

    resource aoaiUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
    name: guid(aoai.id, uami.id, '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')
    scope: aoai
    properties: {
    principalId: uami.properties.principalId
    principalType: 'ServicePrincipal'
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')
    }
    }

    The per-hop sections below give the role name for each service; the grant is always this command with a different role and scope.

    Private VNet — no public data planeAI and toolsData storesPlatform servicesAgent on App Service / Container Appsuser-assigned managed identityone data-plane role per hopAzure OpenAI / FoundryCognitive Services OpenAI UserAzure AI SearchSearch Index Data ReaderSelf-hosted MCP toolsmanaged identity or OBOCosmos DB NoSQLCosmos Built-in Data ContributorAzure SQLdb_datareader / db_datawriterPostgreSQL FlexiblePostgres role via Entra tokenBlob StorageStorage Blob Data ContributorKey VaultKey Vault Secrets UserApp ConfigurationApp Configuration Data ReaderService BusService Bus Data Sender
    Ten hops, one credential. The arrow is the same every time — assign a data-plane role, disable the key, connect with DefaultAzureCredential — so what changes per service is only the role name under it.

    Azure OpenAI and Azure AI Foundry

    Role: Cognitive Services OpenAI User for inference, Cognitive Services OpenAI Contributor to deploy or fine-tune2. Owner and Contributor do not grant inference.

    Disable keys: disableLocalAuth: true (Bicep) or Set-AzCognitiveServicesAccount -DisableLocalAuth $true. The token scope is https://cognitiveservices.azure.com/.default(keyless config).

    var client = new AzureOpenAIClient(
    new Uri("https://my-aoai.openai.azure.com"),
    new DefaultAzureCredential());

    Role: Search Index Data Reader to query, Search Index Data Contributor to load documents, Search Service Contributor to manage indexes (connect using identities).

    Disable keys: az search service update --disable-local-auth (roles only) or --auth-options aadOrApiKey (both, during migration). One ordering quirk: disableLocalAuth must be false before you can set authOptions, so enable RBAC first, then disable keys (enable roles).

    var search = new SearchClient(
    new Uri("https://my-search.search.windows.net"),
    "my-index", new DefaultAzureCredential());

    Cosmos DB

    Role: Cosmos DB Built-in Data Contributor or ...Data Reader: data-plane roles that are separate from control-plane RBAC. There is no portal UI for these; assign them with the CLI or Bicep (Cosmos RBAC):

    Terminal window
    az cosmosdb sql role assignment create \
    --account-name my-cosmos --resource-group rg-agents \
    --role-definition-id 00000000-0000-0000-0000-000000000002 \
    --principal-id "$MI_PRINCIPAL_ID" --scope "/"

    Disable keys:

    Terminal window
    az resource update --resource-group rg-agents --name my-cosmos \
    --resource-type "Microsoft.DocumentDB/databaseAccounts" \
    --set properties.disableLocalAuth=true
    var cosmos = new CosmosClient(
    "https://my-cosmos.documents.azure.com:443/",
    new DefaultAzureCredential());

    Azure SQL Database

    Databases are the exception to the “assign an Azure RBAC role” rhythm, and it trips people up. Azure SQL has no data-plane RBAC. Authorization is entirely SQL-native: contained database users mapped to Entra identities, added to SQL database roles.

    Enforce keyless: Microsoft Entra-only authentication at the server level, which blocks SQL logins outright (Entra-only auth):

    Terminal window
    az sql server ad-only-auth enable --resource-group rg-agents --name my-sqlserver

    Grant access: as the Entra admin, in each database, create the identity as a contained user and add it to roles:

    CREATE USER [agent-identity] FROM EXTERNAL PROVIDER;
    ALTER ROLE db_datareader ADD MEMBER [agent-identity];
    ALTER ROLE db_datawriter ADD MEMBER [agent-identity];

    In tenants where display names can collide, bind by object ID instead: CREATE USER [agent-identity] FROM EXTERNAL PROVIDER WITH OBJECT_ID = '<mi-object-id>';.

    Connect: the portable form works locally (your identity) and in Azure (the managed identity):

    // Active Directory Default: managed identity in Azure, az login locally.
    var cs = "Server=tcp:my-sqlserver.database.windows.net,1433;"
    + "Initial Catalog=agents;Encrypt=True;"
    + "Authentication=\"Active Directory Default\";";
    await using var conn = new SqlConnection(cs);
    await conn.OpenAsync();

    PostgreSQL and MySQL Flexible Server

    The open-source databases use a different keyless mechanism again: the Entra access token is the password.

    Enable: turn on Microsoft Entra authentication (Entra-only or combined) and set an Entra admin. Map the managed identity to a Postgres role with the pgaadauth extension, where the role name must match the identity’s display name (manage Entra roles):

    select * from pgaadauth_create_principal('agent-identity', false, false);

    Connect: acquire a token for scope https://ossrdbms-aad.database.windows.net/.default and pass it as the password. The first-party helper handles username extraction and per-connection token refresh (connect with managed identity):

    var credential = new DefaultAzureCredential();
    var builder = new NpgsqlDataSourceBuilder(
    "Host=my-pg.postgres.database.azure.com;Database=agents;SSL Mode=Require");
    builder.UseEntraAuthentication(credential); // Microsoft.Azure.PostgreSQL.Auth
    await using var dataSource = builder.Build();

    The helper package is still prerelease; install it with dotnet add package Microsoft.Azure.PostgreSQL.Auth --prerelease (there is an async UseEntraAuthenticationAsync too).

    Blob Storage

    Role: Storage Blob Data Contributor or ...Data Reader. Note the trap: Owner, Contributor, and Storage Account Contributor grant only listKeys, the ability to fetch the shared key, not Entra data access (authorize blob access).

    Disable keys: az storage account update --allow-shared-key-access false. The default is permissive if never set, a common audit finding.

    var blob = new BlobServiceClient(
    new Uri("https://mystorage.blob.core.windows.net"),
    new DefaultAzureCredential());

    Key Vault

    Role: Key Vault Secrets User to read secret values, Key Vault Secrets Officer for full CRUD (built-in roles).

    Key Vault is the odd one out: there is no shared key to disable, since it is Entra-authenticated by design. The “keyless” decision here is Azure RBAC versus the legacy access-policy model. Prefer RBAC. Under access policies, anyone with Microsoft.KeyVault/vaults/write can grant themselves data access, a privilege-escalation path RBAC closes off. As of API version 2026-02-01, Azure RBAC is the default for new vaults, and RBAC alone integrates with Privileged Identity Management for just-in-time access.

    var secrets = new SecretClient(
    new Uri("https://my-kv.vault.azure.net"),
    new DefaultAzureCredential());

    Worth a design note: once every other service is keyless, Key Vault holds far fewer secrets. It shifts from “the place all the connection strings live” to holding the handful of genuinely external credentials (a third-party API key, a partner certificate) that have no managed-identity equivalent.

    App Configuration

    Role: App Configuration Data Reader or ...Data Owner, distinct from the control-plane Contributor role (enable RBAC).

    Disable keys: az appconfig update --disable-local-auth true, which deletes all access keys outright; anything still using an old one fails immediately.

    var builder = new ConfigurationBuilder();
    builder.AddAzureAppConfiguration(options =>
    options.Connect(new Uri("https://my-appconfig.azconfig.io"),
    new DefaultAzureCredential()));

    Service Bus and Event Hubs

    Roles: Azure Service Bus Data Sender / ...Data Receiver (and the Event Hubs equivalents Azure Event Hubs Data Sender/Receiver/Owner). As with Event Hubs, Owner and Contributor no longer imply data access (disable local auth).

    Disable keys: disableLocalAuth: true.

    await using var client = new ServiceBusClient(
    "my-namespace.servicebus.windows.net",
    new DefaultAzureCredential());

    MCP tool servers

    Model Context Protocol servers add two legs that both need to be keyless: agent-to-server, and server-to-resource.

    Server to resource is the easy leg: it is every pattern above. Host the MCP server on Azure compute with a managed identity and use RBAC. The official Azure MCP Server already “uses Entra ID through the Azure Identity library.”3

    Agent to server is where the choice matters. Foundry ranks the options from least to most preferred: an API key, then the agent’s Entra identity, then the project’s managed identity, then OAuth identity passthrough, which preserves the user’s identity through to the tool, the OBO pattern from Part 1. Microsoft’s guidance: “When in doubt, start with Microsoft Entra authentication… it eliminates the need to manage secrets and provides built-in token rotation.”4

    This is also where the sprawl shows up first: GitGuardian found 24,000 secrets in MCP config files5. Keyless MCP is not a nicety. It is the difference between a governed tool surface and the next leak.

    The whole table

    ServiceData-plane roleTurn off keysWatch out for
    Azure OpenAI / FoundryCognitive Services OpenAI UserdisableLocalAuthOwner ≠ inference; old keys survive until regenerated
    Azure AI SearchSearch Index Data Reader/Contributor--disable-local-auth403 on propagation; enable RBAC before disabling keys
    Cosmos DBCosmos DB Built-in Data ContributordisableLocalAuthNoSQL/Table only; no portal UI; schema ops via ARM
    Azure SQLcontained user + db_datareader/writerEntra-only authSQL-level, not Azure RBAC; per-database; needs Entra admin
    PostgreSQL / MySQL FlexiblePostgres/MySQL role via pgaadauthEntra-only authtoken = password; short TTL; username must match
    Blob StorageStorage Blob Data ContributorallowSharedKeyAccess=falsebreaks SAS except user-delegation; Owner ≠ data
    Key VaultKey Vault Secrets UserRBAC over access policyno key to disable; RBAC closes an escalation path
    App ConfigurationApp Configuration Data ReaderdisableLocalAuthdeletes keys; ~15-min propagation
    Service Bus / Event HubsData Sender/ReceiverdisableLocalAuthdelete stale SAS policies

    Keyless and private, together

    Keyless removes the secret; a private endpoint removes the public route. Used together, they are Azure’s standard defense in depth, and the policy sets ship in matching pairs: a “disable local auth” policy alongside a “use private link” one, meant to be assigned together.

    Azure AI Search is the clearest worked example. Enable RBAC and assign roles; give the search service its own managed identity for outbound calls; disable public network access and add private endpoints. For first-party services that must still reach it (Azure OpenAI reading a network-isolated index), Search offers a trusted-service bypass: the internet is locked out, but Azure OpenAI reaches the index through its own managed identity, with no key exchanged.

    flowchart LR
    classDef core fill:#1565c0,stroke:#0d47a1,color:#fff
    classDef data fill:#00695c,stroke:#004d40,color:#fff
    classDef ext fill:#ef6c00,stroke:#e65100,color:#fff
    classDef sec fill:#c62828,stroke:#8e0000,color:#fff
    A[Agent<br/>managed identity]:::core -->|private endpoint<br/>Search Index Data Reader| S[Azure AI Search<br/>public access disabled]:::data
    S -->|own managed identity| BLOB[(Blob data source)]:::data
    AOAI[Azure OpenAI]:::ext -->|trusted-service bypass<br/>its managed identity| S
    K[API keys]:::sec -.->|unusable from<br/>outside the VNet| S

    One caveat to plan around: the fully hosted first-party Foundry MCP Server does not support network isolation; only self-hosted MCP servers behind Foundry Agent Service do. Know which MCP surface an agent uses before assuming “private network” covers it.

    Prove a hop, then debug it

    Before shipping, prove a hop with your own identity. If this returns a token but the call still 403s, it is propagation, not configuration:

    Terminal window
    az login
    az account get-access-token --scope https://cognitiveservices.azure.com/.default --query expiresOn -o tsv
    # other scopes: https://cosmos.azure.com/.default · https://database.windows.net/.default · https://storage.azure.com/.default

    And confirm the switch actually took: az storage account show -g rg -n s --query allowSharedKeyAccess or az search service show -g rg -n s --query disableLocalAuth.

    Three failure modes account for most of the rest:

    • 403, not 401. A 401 means no token; a 403 means the token is fine but the role has not propagated. AI Search takes up to ten minutes, App Configuration up to fifteen. Wait before you start changing config.
    • The wrong identity answered. On a host with a user-assigned identity, DefaultAzureCredential may resolve the wrong one, or none. Pass the clientId explicitly, or pin ManagedIdentityCredential as Part 1 recommends.
    • A key still works after you “disabled” it. For Azure OpenAI, disabling local auth does not revoke already-issued keys; regenerate them. For Storage, confirm allowSharedKeyAccess is actually false and not just unset.

    The pattern generalizes

    These are the hops an agent touches most, but the same three steps (data-plane role, disable local auth, connect with DefaultAzureCredential) extend to 30 or 40 more Azure resources: SignalR, Web PubSub, Event Grid, Container Registry, Batch, Data Factory, IoT Hub, and on. The role names change; the shape does not. Once the pattern is muscle memory, adding a new resource to a keyless agent is a lookup, not a project.

    Put together with Part 1, that is a full keyless posture: a governed model in the design, a single identity per workload, an exact role per hop, and Azure Policy making the whole thing the default instead of the exception.

    References

    1. identical across the SDK languages — learn.microsoft.com ↩

    2. RBAC for Azure OpenAI — learn.microsoft.com ↩

    3. “uses Entra ID through the Azure Identity library.” — learn.microsoft.com ↩

    4. “When in doubt, start with Microsoft Entra authentication… it eliminates the need to manage secrets and provides built-in token rotation.” — learn.microsoft.com ↩

    5. 24,000 secrets in MCP config files — thehackernews.com ↩

    Comments

    Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.