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: for each Azure service an agent calls, the exact role, the switch that turns off key auth, the code to connect, and the gotcha that will cost you an afternoon if you skip it. Code is C# with Azure.Identity, but the identity model is identical across the SDK languages; 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. Microsoft’s own guidance is explicit: it is hard to debug which credential answered, the sequential probing adds latency, and — 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”). There is a second reason: reached through the chain, ManagedIdentityCredential runs in “fail fast” mode — no retries; 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:
# 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 — no client secret or certificate is ever stored.
The federated token exchange: a Kubernetes-issued token in, an Entra token out, no secret at rest.
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://AzureADTokenExchangePods 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:
- 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).
- Disable local or shared-key auth on the resource so the key stops being a valid path.
- Connect with
DefaultAzureCredential(or a pinnedManagedIdentityCredential) — 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:
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.
user-assigned managed identity]:::core APP -->|Cognitive Services OpenAI User| AOAI[Azure OpenAI / Foundry]:::ext APP -->|Search Index Data Reader| SEARCH[Azure AI Search]:::data APP -->|Cosmos Built-in Data Contributor| COSMOS[(Cosmos DB NoSQL)]:::data APP -->|db_datareader / db_datawriter| SQL[(Azure SQL)]:::data APP -->|Postgres role via Entra token| PG[(PostgreSQL Flexible)]:::data APP -->|Storage Blob Data Contributor| BLOB[(Blob Storage)]:::data APP -->|Key Vault Secrets User| KV[Key Vault]:::infra APP -->|App Configuration Data Reader| CFG[App Configuration]:::infra APP -->|Service Bus Data Sender| SB[Service Bus]:::infra APP -->|managed identity or OBO| MCP[Self-hosted MCP tools]:::ext end
Azure OpenAI and Azure AI Foundry#
Role: Cognitive Services OpenAI User for inference, Cognitive Services OpenAI Contributor to deploy or fine-tune (RBAC for Azure OpenAI). 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());Gotchas: role propagation takes about five minutes before the first call succeeds. Disabling local auth does not revoke keys someone already copied — regenerate Key1 and Key2 to fully cut them off. And retrieval-augmented setups pull in a cross-service role chain, not a single grant: the OpenAI identity needs Search Index Data Reader and Search Service Contributor on the search service plus Storage Blob Data Contributor on the data source, while the search identity needs Cognitive Services OpenAI Contributor on the embeddings resource and Storage Blob Data Reader on the blob source (On Your Data role assignments).
Azure AI Search#
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());Gotchas: role propagation can take up to ten minutes and surfaces as a 403, not a 401 — do not assume the config is wrong. Behind a private endpoint with public access off, key auth is unusable from outside the VNet anyway, so keyless becomes the only workable path. And if the search service itself reads a blob data source or calls an embedding model, give the search service its own managed identity for those outbound calls (search service identity).
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):
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:
az resource update --resource-group rg-agents --name my-cosmos \
--resource-type "Microsoft.DocumentDB/databaseAccounts" \
--set properties.disableLocalAuth=truevar cosmos = new CosmosClient(
"https://my-cosmos.documents.azure.com:443/",
new DefaultAzureCredential());Gotchas: two honest limits. First, schema operations through the SDK — creating databases or containers — are blocked under Entra auth and must go through ARM, CLI, or Bicep (forbidden exceptions). (Don’t confuse disableLocalAuth, which turns off key auth entirely, with the narrower disableKeyBasedMetadataWriteAccess, which only blocks schema writes via keys.) Second, and this decides architecture: keyless data-plane auth is NoSQL and Table API only. The Cassandra, MongoDB (RU), and Gremlin APIs still authenticate with the account key (security baseline). If a fully keyless design is a hard requirement and you are choosing Cosmos as an agent’s vector or state store, use the NoSQL API.
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):
az sql server ad-only-auth enable --resource-group rg-agents --name my-sqlserverGrant 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();Gotchas: the contained user must be created in every database the identity touches — it is not a server-wide grant (contained users). For a user-assigned identity, add User Id=<clientId> to the connection string. And you cannot enable Entra-only auth until an Entra admin is set on the server. Grant to roles, not directly to users — Microsoft’s own recommendation.
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).
Gotchas: tokens are short-lived — user tokens up to an hour, system-assigned managed-identity tokens up to a day — so acquire one just before connecting and never store it (Entra concepts FAQ). The username must match the principal name exactly. Azure Database for MySQL Flexible Server uses the same token-as-password pattern and scope, with two extra prerequisites: the server needs a user-assigned identity with the Directory Readers role to resolve principals, and the client must support cleartext-password auth (MySQL Entra auth).
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());Gotchas: disabling shared key breaks service SAS and account SAS, because both are signed with the shared key; only user-delegation SAS (signed by Entra) keeps working (prevent shared key). Watch two side effects in shared accounts: Azure Files portal access and Azure Cloud Shell persistence both lean on the account key.
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 — 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()));Gotcha: propagation here is slower than most — allow up to fifteen minutes before the identity can read data.
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());Gotcha: disabling local auth does not delete existing SAS policies — they linger, non-functional. Delete them, so re-enabling local auth by accident does not silently reopen the door.
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.”
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.”
This is also where the sprawl shows up first: GitGuardian found 24,000 secrets in MCP config files. Keyless MCP is not a nicety — it is the difference between a governed tool surface and the next leak.
The whole table#
| Service | Data-plane role | Turn off keys | Watch out for |
|---|---|---|---|
| Azure OpenAI / Foundry | Cognitive Services OpenAI User | disableLocalAuth | Owner ≠ inference; old keys survive until regenerated |
| Azure AI Search | Search Index Data Reader/Contributor | --disable-local-auth | 403 on propagation; enable RBAC before disabling keys |
| Cosmos DB | Cosmos DB Built-in Data Contributor | disableLocalAuth | NoSQL/Table only; no portal UI; schema ops via ARM |
| Azure SQL | contained user + db_datareader/writer | Entra-only auth | SQL-level, not Azure RBAC; per-database; needs Entra admin |
| PostgreSQL / MySQL Flexible | Postgres/MySQL role via pgaadauth | Entra-only auth | token = password; short TTL; username must match |
| Blob Storage | Storage Blob Data Contributor | allowSharedKeyAccess=false | breaks SAS except user-delegation; Owner ≠ data |
| Key Vault | Key Vault Secrets User | RBAC over access policy | no key to disable; RBAC closes an escalation path |
| App Configuration | App Configuration Data Reader | disableLocalAuth | deletes keys; ~15-min propagation |
| Service Bus / Event Hubs | Data Sender/Receiver | disableLocalAuth | delete 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.
managed identity]:::core -->|private endpoint
Search Index Data Reader| S[Azure AI Search
public access disabled]:::data S -->|own managed identity| BLOB[(Blob data source)]:::data AOAI[Azure OpenAI]:::ext -->|trusted-service bypass
its managed identity| S K[API keys]:::sec -.->|unusable from
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:
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/.defaultAnd 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,
DefaultAzureCredentialmay resolve the wrong one — or none. Pass theclientIdexplicitly, or pinManagedIdentityCredentialas 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
allowSharedKeyAccessis actuallyfalseand 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.

