Copying an API key into your config is the easy way to call Azure OpenAI — and the fastest way to fail an enterprise security review. A key is a long-lived secret: it lands in config files, pipelines, sometimes Git history, and every request made with it looks the same, so audits can't tell which app or person made a call. In regulated industries, that combination is a non-starter.
This walkthrough shows the pattern that passes review instead: keyless authentication with Managed Identity and DefaultAzureCredential — the same code locally and in Azure, with key-based access on the resource disabled entirely. It's written for .NET developers who deploy to Azure and want their AI integration to survive contact with a security team.
What changed in 2026
If you last touched Azure OpenAI a year or two ago, four things are different:
- Microsoft Foundry replaces the standalone Azure OpenAI resource as the recommended resource type (provider
Microsoft.CognitiveServices, kindAIServices). A custom subdomain is required for Entra ID auth. - The RBAC role for inference is "Foundry User" — recently renamed from "Azure AI User", same role ID. Microsoft advises against the older
Cognitive Services *roles for Foundry scenarios. - The official OpenAI .NET SDK against the OpenAI v1 endpoint (
https://<resource>.openai.azure.com/openai/v1) replaces the deprecated Azure-specific inference SDKs. No moreapi-versionjuggling. - The token scope for Foundry is
https://ai.azure.com/.default— the bridge betweenAzure.Identityand the OpenAI SDK isBearerTokenPolicy.
How it works
The API runs on App Service with a system-assigned Managed Identity — think of it as a service account that Azure manages: no password, no key. When the app wants to call the model, it requests a token from Entra ID, which checks whether that identity holds the Foundry User role on the resource. That role allows calling the models and nothing else.
DefaultAzureCredential is what makes the same code work everywhere. It's not one credential but a chain: the code tries different credential sources and takes the first one that works. On your laptop there is no Managed Identity, so it uses your Azure CLI login; on App Service it finds the Managed Identity. No if statement, no special config — which also means your own user needs the Foundry User role too, so local development works.
The keyless code
The whole API is a .NET 10 minimal API with one endpoint. The keyless part is about ten lines — going keyless is not a big rewrite:
// DefaultAzureCredential tries a chain of credential sources:// locally it picks up your Azure CLI login (az login),// on Azure App Service it uses the system-assigned Managed Identity.var credential = new DefaultAzureCredential();// "https://ai.azure.com/.default" is the token scope// for Microsoft Foundry resources.var tokenPolicy = new BearerTokenPolicy(credential, "https://ai.azure.com/.default");return new ChatClient(model: deployment,authenticationPolicy: tokenPolicy,options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) });
Check the token scope string first: it must be https://ai.azure.com/.default for Foundry resources. The BearerTokenPolicy constructor overload is still marked experimental (OPENAI001), so you'll need a pragma to suppress the warning.
The config holds only the endpoint and the deployment name — no secrets. The real endpoint lives in a gitignored appsettings.local.json, which is why the repo can be public.
The infrastructure in Terraform
In real projects you don't click this together in the portal. Three details in the Terraform matter most:
resource "azurerm_cognitive_account" "foundry" {kind = "AIServices"sku_name = "S0"# A custom subdomain is required for Entra ID authentication.custom_subdomain_name = "foundry-${var.base_name}-${random_string.suffix.result}"# Keys are not just unused - they are disabled.# Every request must carry an Entra ID token.local_auth_enabled = false}
- The custom subdomain is required. Without it, Entra ID auth does not work — and the error message will not tell you why.
local_auth_enabled = falsedisables key-based access entirely. When the security review asks how you rotate your keys, the answer is: there are no keys.- Role assignments are minimal. The web app's Managed Identity gets the Foundry User role scoped to this one resource — not the resource group, not the subscription. Your own user gets the same role for local development. The Terraform references the role by GUID (
53ca6127-db72-4b80-b1b0-d745d6d5456d), so the "Azure AI User" → "Foundry User" rename can't break it.
Models retire on a schedule, so check what your region offers before pinning a version:
az cognitiveservices model list --location swedencentral
Run it locally, then deploy
az logincd infracp terraform.tfvars.example terraform.tfvars # fill in subscription + your object IDterraform initterraform apply
Point the app at the openai_v1_endpoint output via a gitignored appsettings.local.json, then:
cd src/ChatApidotnet runcurl -X POST http://localhost:5002/chat \-H "Content-Type: application/json" \-d '{ "message": "Hello, who are you?" }'
No key anywhere on your machine — DefaultAzureCredential picked up your az login and your Foundry User role did the rest. Deployment is one dotnet publish and one az webapp deploy; the exact commands are in the repo's README.
What a security review actually looks at is the Azure portal afterwards: the App Service shows a system-assigned identity with no password to leak; that identity holds exactly one role on exactly one resource — the whole blast radius if the app is ever compromised; and the resource's Keys page is disabled. Nothing to steal.
Role assignments can take a few minutes to propagate. Wait and retry — don't start debugging before ~5 minutes have passed.
What you'll need
- An Azure subscription
- Azure CLI, logged in with
az login - .NET 10 SDK
- Terraform >= 1.9
In a real enterprise setup there's more on top of this — user-assigned identities, security groups, and an API Management gateway in front of the models — but the keyless pattern shown here is the foundation all of that builds on.


