Migrate Azure OpenAI apps to Managed Identities and RBAC, eliminate API keys, strengthen security, and improve access governance.
Purging the Keys: Migrating Azure OpenAI Applications
to Managed Identities and RBAC
An API key is a password with no name attached to it. It doesn't expire on its own, it doesn't know who is using it, and once it is in a commit history, a log line, or a Slack message, it is out — permanently, whether or not anyone ever finds it. The fix is not "rotate the key faster." The fix is to stop having a key at all.
# The commit that starts the incident. Nothing exotic — just a debug
# print statement that made it past code review, six months ago:
app/services/openai_client.py:
+ print(f"Connecting with key: {settings.AZURE_OPENAI_API_KEY}")
+ client = AzureOpenAI(api_key=settings.AZURE_OPENAI_API_KEY, ...)
# GitHub's own secret scanning catches it eventually — but "eventually"
# in this business means AFTER a scraper bot already has it:
GitHub Advanced Security — Secret scanning alert
Type: Azure OpenAI API Key
Repository: your-org/internal-chat-service (public fork detected)
Committed: 187 days ago
Status: Exposed — key is VALID
# Meanwhile, on the Azure OpenAI resource's own usage metrics:
Requests (last 24h): 412,880 ← your normal traffic: ~3,000/day
TokensProcessed (last 24h): 1.9 billion ← someone else is using your key
Estimated unexpected spend: ~$14,200 ← and paying nothing for it
# The fix this article ships is not "rotate the key." It's this:
PATCH .../providers/Microsoft.CognitiveServices/accounts/{name}?api-version=2024-10-01
{ "properties": { "disableLocalAuth": true } }
# → No key, rotated or otherwise, can authenticate to this resource again.Symptom: Anomalous token consumption and request volume on an Azure OpenAI resource with no corresponding change in your own application traffic. Failure point: A static API key — committed to source control, printed to logs, or pasted into a support ticket — was scraped and reused outside your control. Default platform behaviour: Azure OpenAI resources ship with local (key-based) authentication enabled by default. Nothing warns you when a key leaves your control, because the platform has no way to know.
Astatic API key is, functionally, a bearer credential with no expiry, no identity, and no audit trail beyond "someone with this string." It authenticates anyone who holds it, indefinitely, until someone remembers to rotate it — which in practice means almost never, because rotating it means finding every place it is used first, and that list is usually longer and less documented than anyone expects. The fix Azure actually offers is not a better way to manage keys. It is the option to stop having them: Microsoft Entra ID token-based authentication, backed by managed identities for anything running in Azure, and Azure RBAC for the fine-grained authorization that a shared secret can never express. This is not a theoretical best practice — it is a property on the resource, a role you assign, and an SDK constructor call, and once it is done, the class of incident where a key leaks into a public repository stops being possible, because there is no longer a key to leak.
API keys don't leak because developers are careless. They leak because a static string that authenticates successfully has no natural place to stop moving. It gets pasted into a support ticket to reproduce a bug. It ends up in a Postman collection someone exports and shares. It gets printed in a debug log that ships to a third-party log aggregator with looser access controls than the resource itself. It sits in an environment variable file that someone git add -As by habit. None of these are exotic mistakes — they are the ordinary behaviour of a string that works everywhere it's pasted, with no way to tell where "everywhere" has come to include.
The asymmetry is what makes it dangerous. A leaked key does not need to be found by a sophisticated attacker; automated scanners crawl public GitHub continuously, specifically looking for provider key formats. And a key sitting in a private repo is not much safer — a compromised laptop, a misconfigured CI runner, or a departing contractor with local clones all reach the same string with the same permissions.
| Leak vector | Why it happens | Why key rotation doesn't fully fix it |
|---|---|---|
| Committed to source control | .env files, config committed by habit, key hardcoded during a debugging session | Git history retains it forever unless the repo is rewritten — rotation only stops the OLD key working |
| Printed in application logs | Debug logging left in, or an exception handler dumping request context | Log retention windows are often longer than anyone remembers to check |
| Shared in tickets or chat | "Here's the key, can you test this" during an incident | Chat history and ticket systems are rarely scoped as tightly as the resource itself |
| Baked into a client-side app | Mobile or browser code calling Azure OpenAI directly | Client-side secrets are, definitionally, distributed to every user of the app |
Rotating a key after a leak is necessary triage, but it treats the symptom. It does nothing about the git history that still contains the old value, the log retention that still has it, or the next developer who pastes a key into a place that seemed private at the time. The actual fix removes the category of artifact that can leak: don't have a long-lived shared secret that works from anywhere. That is precisely what Microsoft Entra ID token-based authentication is for.
Azure ships two purpose-built roles for Azure OpenAI, and understanding the difference is the whole ballgame for least privilege. Neither of them, used correctly, can view or regenerate the resource's API keys — which is worth sitting with, because it means the role you'd assign to a production application structurally cannot leak a key, even if the identity holding it were somehow compromised.
| Role | Grants | Cannot do | Assign to |
|---|---|---|---|
| Cognitive Services OpenAI User | Inference: chat completions, embeddings, image generation, view models/deployments/files | View or regenerate keys, create resources, access quota, manage deployments | Production applications, service principals, managed identities |
| Cognitive Services OpenAI Contributor | Everything in OpenAI User, plus: create/edit deployments, fine-tuning, upload training data, Assistants API, On Your Data sources | View or regenerate keys, create new Azure OpenAI resources | Platform/ML engineers managing deployments, not runtime application identities |
Notice what neither role grants: Microsoft.CognitiveServices/accounts/listkeys/action. That single data action is the one that lets a principal retrieve the resource's API keys programmatically, and it is deliberately absent from both purpose-built OpenAI roles. It shows up in the broader Cognitive Services User role and in Contributor/Owner at the subscription or resource-group level — which is exactly why those broader roles are the wrong choice for an application identity, even though they happen to also grant inference access.
This is the detail that turns a well-intentioned migration into a false sense of security. A team disables local auth, sets up managed identity, does everything right — and then grants the application's managed identity Contributor at the resource group level "to keep things simple." Contributor includes listkeys. The moment that identity is compromised, the attacker doesn't need to steal a token — they can just call the management API and pull a fresh key, re-enabling exactly the attack surface you just spent an afternoon removing. Audit every role assignment on every identity that touches an Azure OpenAI resource specifically for this permission.
Not every caller of your Azure OpenAI resource is the same kind of principal, and picking the right identity type for each one keeps the migration clean instead of turning into a pile of workarounds.
| Caller | Identity type | Why |
|---|---|---|
| App Service, Function App, Container App, AKS pod | System-assigned managed identity | Lifecycle tied to the resource. Deleted automatically when the resource is. No separate identity to manage |
| Shared across multiple compute resources (e.g. a fleet of Function Apps) | User-assigned managed identity | Provisioned once, attached to many resources. Survives any single resource's deletion — useful when you want one identity's RBAC assignments to apply fleet-wide |
| Developer running code locally | Their own Entra ID user account, via CLI/IDE sign-in | DefaultAzureCredential picks this up automatically from an az login session — no separate credential needed |
| CI/CD pipeline | Federated workload identity (OIDC) or user-assigned managed identity | Avoids a service principal secret in pipeline variables — same "no static secret" principle, applied to the pipeline itself |
| Third-party SaaS calling your Azure OpenAI resource | Service principal with a client secret or certificate, tightly scoped | Sometimes unavoidable when the caller isn't Azure-hosted — but scope the role as narrowly as any other identity, and prefer certificate auth over client secrets |
Microsoft Entra ID authentication against Azure OpenAI requires the resource to have a custom subdomain — the default *.openai.azure.com-style endpoint with a resource-specific name, not a raw regional endpoint. If your resource predates this and was provisioned without one, token-based auth will not work until you add it. Most modern deployments get this by default, but it's worth confirming before you build the rest of the migration on top of an assumption.
Architectural Topology: Failing vs Remediated
| Layer | Failing configuration (current) | Remediated configuration (fix) |
|---|---|---|
| Credential | Static API key in env var, config file, or hardcoded string | Microsoft Entra ID token, requested at runtime, short-lived |
| SDK auth | api_key=... constructor argument | azure_ad_token_provider via DefaultAzureCredential |
| Identity for Azure-hosted apps | None — shared secret does the job instead | System- or user-assigned managed identity |
| Identity for local dev | Same shared key as production, copied to .env | Developer's own Entra ID account via CLI sign-in |
| Role assigned to app | None needed — key bypasses RBAC entirely | Cognitive Services OpenAI User, scoped to the resource |
| listkeys exposure | Whoever has the key has full access; roles are irrelevant | Audited out of every application-facing role assignment |
| Resource property | disableLocalAuth unset (local auth enabled) | disableLocalAuth: true — keys structurally cannot authenticate |
| Audit trail | "A valid key was used" — no caller identity | Entra ID sign-in logs + RBAC assignment per named/managed identity |
Two steps, and they are independent of each other: give the compute resource an identity, then grant that identity the narrowest role that does the job. Do this first, before touching application code — you can validate the identity has access before you ever change how the app authenticates.
It is tempting to assign the role at the resource group scope "so it covers anything I add later." Don't. A resource-group-scoped Cognitive Services OpenAI User assignment grants that identity inference access to every Azure OpenAI resource in the group, including ones added after the fact for a completely different application. Scope the assignment to the specific resource. It's marginally more Bicep, and it means an application identity's blast radius is exactly one resource, not "everything in this resource group, forever."
After creating the role assignment, Azure documents that it can take up to five minutes for the change to take effect. If you deploy the identity, the role assignment, and the application code swap all in the same pipeline run and the app immediately starts throwing 401s, this is very often the reason — not a misconfiguration. Build a short wait or a retry-with-backoff into first-run validation rather than assuming an instant failure means the setup is wrong.
This is the part that surprises people with how little code changes. DefaultAzureCredential from the Azure Identity library tries a sequence of credential sources automatically — environment variables, managed identity, Azure CLI login, Visual Studio, and others — and uses whichever one succeeds. That means the exact same code authenticates correctly whether it's running on your laptop (via az login) or in Azure (via managed identity), with no environment-specific branches.
A common first-run mistake: requesting a token scoped to the Azure Resource Manager audience (https://management.azure.com/.default) instead of the Cognitive Services data-plane audience (https://cognitiveservices.azure.com/.default). The management-scoped token is what you'd use to call the ARM API — for example, to list or manage the resource itself — and it will not authenticate an inference call. If you see 401s that specifically look like an audience mismatch rather than a missing role, check the scope string first.
If Azure OpenAI sits behind Azure API Management, the same pattern applies one layer out. Give APIM a system-assigned identity, assign it Cognitive Services OpenAI User on the backend resource, and use the authentication-managed-identity policy in your APIM policy XML to fetch a token and inject it as the Authorization header on the backend call — so the eventual caller of your API never needs to see an Azure OpenAI credential at all, key or token.
The two built-in roles cover almost every case, but occasionally you need something narrower still — for example, a human data scientist who should be able to run inference and view metrics through the portal, but who you don't want anywhere near listkeys, even though the broader Cognitive Services User role would otherwise fit their day-to-day needs. Build a custom role that starts from the built-in and strips the dangerous action.
In practice, you rarely need a custom role for an application identity — Cognitive Services OpenAI User already excludes listkeys and is as narrow as a service typically needs. Custom roles earn their complexity for human access patterns, where you're trying to give someone enough of the portal experience to be productive (view metrics, browse deployments, test in the playground) without handing them the one permission that undoes the whole migration. Keep the custom-role surface area small; every custom role is one more definition someone has to remember to audit.
Everything up to this point is additive — you can provision identities, assign roles, and even switch application code to token auth, all while the API key keeps working as a safety net. This step removes the safety net. disableLocalAuth: true makes API-key authentication structurally impossible on the resource — not disabled in a way a portal click can quietly re-enable, but rejected at the platform level for every request that presents a key instead of a token.
Historically, the Studio/Foundry portal experience relied on API keys to function under the hood. With local auth disabled, portal access depends entirely on the signed-in user's own RBAC role assignment on the resource. Anyone without Cognitive Services OpenAI User or Contributor will find the portal appears broken — playground calls fail, deployments don't load — with no obvious error pointing at "you need a role assignment." Communicate this change before you make it, and make sure every human who needs portal access has a role assigned first.
A one-time az resource update is easy to reverse by accident — someone flips it back "temporarily" to unblock a legacy tool and forgets. Layer an Azure Policy definition that audits (and eventually denies) any Azure OpenAI resource where disableLocalAuth is not true. Start in Audit mode so you get visibility without breaking anything mid-migration, then move to Deny once you've confirmed every resource and every caller has actually completed the migration — flipping to Deny too early, before the audit is clean, is exactly how you turn a security improvement into an outage.
Because the individual steps are simple, teams often declare victory before the migration is actually complete. These are the specific ways that happens.
| Anti-pattern | Why it feels right | Why it isn't |
|---|---|---|
| Grant Contributor "to keep it simple" | "One role, fewer things to configure" | Includes listkeys. A compromised identity can re-mint a static key and undo the entire migration |
| Assign the role at resource-group scope | "Covers future resources automatically" | Blast radius becomes every AOAI resource in the group, not just the one this app needs |
| Disable local auth before confirming ALL callers migrated | "The main app is switched over, ship it" | Forgotten callers — a nightly batch script, a third-party integration, an old Postman collection someone still uses — break silently at the worst time |
| Leave the old key in Key Vault "just in case" | "Rollback safety net" | Once disableLocalAuth=true, the key is inert — but it's still a stored secret that can leak on its own, for zero benefit. Delete it once the migration is verified |
| Request a management-scope token for inference calls | "Any Azure token should work, right?" | Wrong audience. https://management.azure.com/.default authenticates ARM calls, not Cognitive Services inference. Use https://cognitiveservices.azure.com/.default |
| Treat the migration as done once code compiles | "No errors, must be working" | A missing role assignment surfaces as a 401 at runtime, not a compile error. Test the actual credential path before calling it complete |
Validation & Verification: Confirm the Fix
Because this migration has real failure modes on both sides — leaving key auth reachable, or disabling it before every caller has moved — validate methodically before declaring it complete.
Four conditions must hold together. One: every legitimate caller authenticates via a managed identity or Entra ID user token, verified by actually acquiring and using a token, not just by code review. Two: a 7-day (minimum) Azure Monitor query shows zero remaining key-based requests before you touch disableLocalAuth. Three: after disabling local auth, a deliberate test with the old key returns 401 — proof the fix is structural, not configured-and-hoped-for. Four: no role assignment on the resource includes the listkeys data action, for any principal, human or application. Miss any of the four and either a forgotten caller breaks in production, or a compromised identity can quietly re-mint the exact secret you just eliminated.
Key Takeaways
Frequently Asked Questions
Related FAVRITE Articles
- Fixing First-Request Lag: Azure Functions and Container Apps for AI Microservices
- Azure OpenAI to Microsoft Foundry: Fixing Private Endpoint & DNS Failures
- RBAC for Azure Files: Least-Privilege Access Patterns
- Microsoft Defender for Cloud: A Practical Guide