Logo
  • English icon
  • German icon

Azure OpenAI Without API Keys in .NET — Managed Identity on Microsoft Foundry

azure-openaimanaged-identityentra-iddotnetterraformmicrosoft-foundry

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, kind AIServices). 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 more api-version juggling.
  • The token scope for Foundry is https://ai.azure.com/.default — the bridge between Azure.Identity and the OpenAI SDK is BearerTokenPolicy.

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) });
ℹ️If auth fails with a valid identity

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
}
  1. The custom subdomain is required. Without it, Entra ID auth does not work — and the error message will not tell you why.
  2. local_auth_enabled = false disables key-based access entirely. When the security review asks how you rotate your keys, the answer is: there are no keys.
  3. 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 login
cd infra
cp terraform.tfvars.example terraform.tfvars # fill in subscription + your object ID
terraform init
terraform apply

Point the app at the openai_v1_endpoint output via a gitignored appsettings.local.json, then:

cd src/ChatApi
dotnet run
curl -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.

⚠️401 right after terraform apply is expected

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

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.

Video transcript

This is the easy way to call Azure OpenAI: you copy the API key, you put it in the config, and it works. But if you bring this to a real company, the security review will reject it. This line is the problem. I build software for enterprises in Germany — energy sector, regulated industry — and there, API keys in config files are not accepted. In this video, I show you the pattern that is accepted: keyless authentication with Managed Identity. Same code locally and in Azure, no key anywhere. At the end, you will have a working setup: a .NET API, the infrastructure in Terraform, and the proof in the Azure portal. Everything is in a public repo, link in the description.

First, let's talk about why keys are a problem. There are three points. First, a key is a secret. It lands in config files, in pipelines, sometimes in Git history. If it leaks, anyone can use it. Second, there is no traceability. Every request with the key looks the same — you cannot say which app or which person made the call. For audits, that's a big problem. Third, someone has to rotate the key. Extra work, and things break when you do it. With Managed Identity, there is no secret. Every caller has an identity, and Azure manages everything.

Let me show you how it works. Here is the whole idea: our API runs on App Service with a system-assigned Managed Identity. Think of it like a service account that Azure manages — no password, no key. When the app wants to call the model, it gets a token from Entra ID. Entra ID checks: does this identity have the Foundry User role on this resource? Foundry User is the built-in role that allows calling the models and nothing else. No key in this picture, and every call is connected to an identity. You can see in the logs exactly who called what.

And now the best part: DefaultAzureCredential. This is not one credential — it's a chain. The code tries different credential sources and takes the first one that works. On my laptop, there is no Managed Identity, so it uses my Azure CLI login. In Azure, it finds the Managed Identity and uses that. Same code, both places. No if statement, no special config. This also means for local development my own user needs the Foundry User role too.

This is the whole API: a minimal API in .NET 10, one endpoint. The important part is here: we use the official OpenAI SDK — two things make it work with Azure. First, the endpoint: our Foundry resource with /openai/v1 at the end. Second, these two lines: instead of an API key, we create a DefaultAzureCredential and wrap it in a BearerTokenPolicy with the scope ai.azure.com. Remember this scope — ai.azure.com. If your auth ever fails with a valid identity, check this string first. And look at this: this is the full difference between the key version and the keyless version — about ten lines. Going keyless is not a big rewrite. The config only has the endpoint and the deployment name, no secrets. My real endpoint is in a local file that is gitignored, so this repo can be public — and it is, link in the description.

The infrastructure is in Terraform, because in real projects you don't click this together in the portal. Three things I want to show you. First, the Foundry resource. Important detail: it needs a custom subdomain. Without it, Entra ID auth does not work, and the error message will not tell you why. Second, my favorite line: local auth disabled. The resource does not accept keys at all. When the security review asks "how do you rotate your keys?", the answer is: there are no keys. The model deployment — gpt-5.4-mini here. Models retire on a schedule, so check what your region offers before you pin a version; this command is for that. And third, the role assignments. The app's identity gets the Foundry User role, only on this one resource, nothing more. And my own user gets the same role, so local development works.

I already deployed the API to an App Service in Azure. The steps are in the README — it's one publish and one az webapp deploy command. What I want to show you instead is how this looks in Azure, because this is what a security review actually looks at. Here is the App Service, under Identity: you see the system-assigned Managed Identity is on, this object ID. This is the app as an identity in Entra ID. Nobody knows a password for it, because there is none. And here you see what this identity can do: exactly one role, Foundry User, on exactly one resource — not the resource group, not the subscription. If this app is ever compromised, this is the whole blast radius. Same picture from the other side: on the Foundry resource, two identities have access — the app, and me, for local development. This list is your answer when someone asks "who can call our AI models?". And the Keys page: disabled. Nothing to steal here.

And now the proof. Locally — no key on this machine, my CLI login does the auth — it works. And the same request against the deployed app also works. Same code, different identity.

Everything is in the repo, link in the description: code, Terraform, and a README with the exact steps and the errors you might see — like a 401 directly after terraform apply. That one is normal; role assignments need a few minutes. In a real enterprise setup there is more: user-assigned identities, security groups, and an API Management gateway in front of the models. If you build AI on the Microsoft stack for real projects, subscribe. And if your team works on exactly this right now, my LinkedIn is in the description. Thanks for watching.

Questions about this build?

If you want to discuss this architecture, need help with a similar integration, or are looking for enterprise support with Azure AI, .NET, or Dynamics 365 — I'm happy to talk.

Get in touch