Skip to main content

Migrate Azure OpenAI apps to Managed Identities and RBAC, eliminate API keys, strengthen security, and improve access governance.

Security MigrationManaged IdentityEntra IDRBACdisableLocalAuth

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 failure signature this guide resolves
# 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.

disableLocalAuth
The one resource property that makes API keys stop working entirely — not rotated, not restricted, structurally unusable
OpenAI User
Cognitive Services OpenAI User — the narrowest built-in role for inference. Cannot view or regenerate keys, cannot create resources
listkeys
The single data action — Microsoft.CognitiveServices/accounts/listkeys/action — that must never appear in a role granted to an app or service principal
Zero code
DefaultAzureCredential authenticates identically on a laptop (CLI login) and in Azure (Managed Identity) — no branching logic required

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.

Figure 1 — Two authentication paths to the same resource: shared secret vs identity token
TODAY — a string anyone can copy, paste, and reuse foreverYour app codeapi_key = "sk-...abcd"(committed, logged, pasted)headerAzure OpenAIchecks: is this stringa valid key? (that's ALL)No identity. No audit trail beyond"a valid key was presented." Anyoneholding the string is indistinguishablefrom your production app.AFTER — a scoped, short-lived, auditable identity tokenYour appDefaultAzureCredential()no secret in code1. request tokenMicrosoft Entra IDverifies identity, issuesshort-lived JWT (~1hr)2. bearer tokenAzure OpenAIchecks: valid token +RBAC role assignmentEvery call is attributable to a named identity, scoped by RBAC role,and expires on its own. Nothing to leak that still works tomorrow.
A static API key authenticates the string, not the caller — anyone holding it is indistinguishable from the application. A Microsoft Entra ID token authenticates a specific identity, is scoped by an Azure RBAC role assignment, and expires on its own (typically around one hour). The credential itself is worthless outside the context that issued it.
01Why API Keys Leak: The Shape of the ProblemRoot Cause

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 vectorWhy it happensWhy key rotation doesn't fully fix it
Committed to source control.env files, config committed by habit, key hardcoded during a debugging sessionGit history retains it forever unless the repo is rewritten — rotation only stops the OLD key working
Printed in application logsDebug logging left in, or an exception handler dumping request contextLog 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 incidentChat history and ticket systems are rarely scoped as tightly as the resource itself
Baked into a client-side appMobile or browser code calling Azure OpenAI directlyClient-side secrets are, definitionally, distributed to every user of the app
Rotation is a mitigation, not a fix

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.

02The Two Built-In Roles (and the Permission That Should Never Leave Your Hands)Concept

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.

RoleGrantsCannot doAssign to
Cognitive Services OpenAI UserInference: chat completions, embeddings, image generation, view models/deployments/filesView or regenerate keys, create resources, access quota, manage deploymentsProduction applications, service principals, managed identities
Cognitive Services OpenAI ContributorEverything in OpenAI User, plus: create/edit deployments, fine-tuning, upload training data, Assistants API, On Your Data sourcesView or regenerate keys, create new Azure OpenAI resourcesPlatform/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.

If a role can call listkeys, it can undo everything else in this article

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.

03Which Identity: System-Assigned, User-Assigned, or HumanDecision

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.

CallerIdentity typeWhy
App Service, Function App, Container App, AKS podSystem-assigned managed identityLifecycle 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 identityProvisioned 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 locallyTheir own Entra ID user account, via CLI/IDE sign-inDefaultAzureCredential picks this up automatically from an az login session — no separate credential needed
CI/CD pipelineFederated workload identity (OIDC) or user-assigned managed identityAvoids a service principal secret in pipeline variables — same "no static secret" principle, applied to the pipeline itself
Third-party SaaS calling your Azure OpenAI resourceService principal with a client secret or certificate, tightly scopedSometimes 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
A custom subdomain is a prerequisite, not a nice-to-have

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

LayerFailing configuration (current)Remediated configuration (fix)
CredentialStatic API key in env var, config file, or hardcoded stringMicrosoft Entra ID token, requested at runtime, short-lived
SDK authapi_key=... constructor argumentazure_ad_token_provider via DefaultAzureCredential
Identity for Azure-hosted appsNone — shared secret does the job insteadSystem- or user-assigned managed identity
Identity for local devSame shared key as production, copied to .envDeveloper's own Entra ID account via CLI sign-in
Role assigned to appNone needed — key bypasses RBAC entirelyCognitive Services OpenAI User, scoped to the resource
listkeys exposureWhoever has the key has full access; roles are irrelevantAudited out of every application-facing role assignment
Resource propertydisableLocalAuth unset (local auth enabled)disableLocalAuth: true — keys structurally cannot authenticate
Audit trail"A valid key was used" — no caller identityEntra ID sign-in logs + RBAC assignment per named/managed identity
05Fix 1 — Provision the Managed Identity and Assign the RoleThe Fix

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.

Bicep — system-assigned identity on the compute resource + scoped role assignment// Example: an App Service. The pattern is identical for Function Apps, // Container Apps, AKS pod-managed identities, and VMs. resource appService 'Microsoft.Web/sites@2023-12-01' = { name: 'app-chat-prod' location: location identity: { type: 'SystemAssigned' // the whole provisioning step } properties: { /* ... */ } } resource openAiAccount 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { name: 'aoai-prod-eastus' } // Cognitive Services OpenAI User role ID — verified, do not substitute var openAiUserRoleId = '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(openAiAccount.id, appService.id, openAiUserRoleId) scope: openAiAccount // SCOPED to this one resource — not the RG properties: { roleDefinitionId: subscriptionResourceId( 'Microsoft.Authorization/roleDefinitions', openAiUserRoleId) principalId: appService.identity.principalId principalType: 'ServicePrincipal' } }
Azure CLI — the same, imperatively (useful for a one-off migration)# 1. Enable system-assigned identity on the App Service az webapp identity assign \ --name app-chat-prod \ --resource-group rg-ai-prod # 2. Capture the principal ID it was just given PRINCIPAL_ID=$(az webapp identity show \ --name app-chat-prod \ --resource-group rg-ai-prod \ --query principalId -o tsv) # 3. Assign the role, SCOPED TO THE RESOURCE (not the resource group) az role assignment create \ --role "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd" \ --assignee-object-id "$PRINCIPAL_ID" \ --assignee-principal-type ServicePrincipal \ --scope "/subscriptions/$SUB_ID/resourceGroups/rg-ai-prod/providers/Microsoft.CognitiveServices/accounts/aoai-prod-eastus"
Scope to the resource, not the resource group

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."

Role propagation takes up to five minutes

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.

06Fix 2 — Swap the SDK Auth Path (Zero Branching Logic)The Fix

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.

Python — before and after, side by side# BEFORE — static key, works everywhere the string is pasted from openai import AzureOpenAI client = AzureOpenAI( api_key=os.environ["AZURE_OPENAI_API_KEY"], # the liability api_version="2024-10-21", azure_endpoint="https://aoai-prod-eastus.openai.azure.com/", ) # AFTER — identity token, works only for the identity running the code from openai import AzureOpenAI from azure.identity import DefaultAzureCredential, get_bearer_token_provider credential = DefaultAzureCredential() token_provider = get_bearer_token_provider( credential, "https://cognitiveservices.azure.com/.default" ) client = AzureOpenAI( azure_ad_token_provider=token_provider, # no key, anywhere api_version="2024-10-21", azure_endpoint="https://aoai-prod-eastus.openai.azure.com/", ) # Same client.chat.completions.create(...) calls below this line. # Nothing about your application logic changes. Only how the client authenticates.
.NET / C# — the same patternusing Azure.AI.OpenAI; using Azure.Identity; // BEFORE AzureOpenAIClient client = new( new Uri("https://aoai-prod-eastus.openai.azure.com/"), new AzureKeyCredential(apiKey)); // the liability // AFTER AzureOpenAIClient client = new( new Uri("https://aoai-prod-eastus.openai.azure.com/"), new DefaultAzureCredential()); // no key, anywhere
JavaScript / TypeScript — the same patternconst { AzureOpenAI } = require("openai"); const { DefaultAzureCredential, getBearerTokenProvider } = require("@azure/identity"); // AFTER — token provider replaces the api-key string entirely const credential = new DefaultAzureCredential(); const azureADTokenProvider = getBearerTokenProvider( credential, "https://cognitiveservices.azure.com/.default" ); const client = new AzureOpenAI({ azureADTokenProvider, endpoint: "https://aoai-prod-eastus.openai.azure.com/", apiVersion: "2024-10-21", });
The token audience is cognitiveservices.azure.com, not management.azure.com

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.

For APIM-fronted deployments: the managed identity policy

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.

Figure 2 — The phased migration: never a single cutover moment
FOUR PHASES — each one independently safe to ship; disableLocalAuth is the LAST step, not the firstPHASE 1 — Provision identity + role (Section 5)Both key AND identity work. Zero risk — nothing about existing traffic changes yet.SAFEPHASE 2 — Swap SDK auth in application code (Section 6)Deploy behind a feature flag or canary. Key still valid as a rollback path.SAFEPHASE 3 — Inventory EVERY caller; confirm zero key-based traffic (Section 9)Query Azure Monitor logs for key-auth requests. Anything still using the key MUST be found here.VERIFYPHASE 4 — disableLocalAuth = true (Section 8) — the point of no return, done LASTFINAL
The migration has no single cutover moment. Identity and key auth coexist through Phases 1 and 2, so nothing breaks while you provision and roll out. Phase 3 is where the discipline lives — you cannot skip verifying that every caller has actually migrated. Only once that's confirmed does Phase 4 remove the fallback, permanently.
07Fix 3 — Build a Custom Role When Built-Ins Are Too BroadAdvanced

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.

Custom role definition — Cognitive Services User, minus listkeys{ "Name": "Cognitive Services OpenAI User (No Key Access)", "IsCustom": true, "Description": "Full data-plane access to Azure OpenAI resources for human users, explicitly excluding the ability to view or regenerate API keys.", "Actions": [ "Microsoft.CognitiveServices/*/read", "Microsoft.Insights/alertRules/*", "Microsoft.ResourceHealth/availabilityStatuses/read", "Microsoft.Resources/deployments/*", "Microsoft.Support/*" ], "NotActions": [ "Microsoft.CognitiveServices/accounts/listkeys/action" // the whole point ], "DataActions": [ "Microsoft.CognitiveServices/accounts/OpenAI/*" ], "NotDataActions": [], "AssignableScopes": [ "/subscriptions/{subscriptionId}/resourceGroups/rg-ai-prod" ] }
Azure CLI — create and assign the custom roleaz role definition create --role-definition custom-role.json az role assignment create \ --role "Cognitive Services OpenAI User (No Key Access)" \ --assignee-object-id "$USER_OBJECT_ID" \ --assignee-principal-type User \ --scope "/subscriptions/$SUB_ID/resourceGroups/rg-ai-prod"
Custom roles are for humans; built-ins are for applications

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.

08Fix 4 — Disable Local Auth (the Point of No Return, Done Safely)Critical

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.

Bicep — disableLocalAuth as part of the resource definitionresource openAiAccount 'Microsoft.CognitiveServices/accounts@2024-10-01' = { name: 'aoai-prod-eastus' location: location kind: 'OpenAI' sku: { name: 'S0' } identity: { type: 'SystemAssigned' } properties: { customSubDomainName: 'aoai-prod-eastus' // required for Entra ID auth disableLocalAuth: true // the point of no return publicNetworkAccess: 'Disabled' // pairs well with this change networkAcls: { defaultAction: 'Deny' } } }
Azure CLI — apply to an existing resourceaz resource update \ --name aoai-prod-eastus \ --resource-group rg-ai-prod \ --resource-type "Microsoft.CognitiveServices/accounts" \ --set properties.disableLocalAuth=true # Confirm it took effect: az resource show \ --name aoai-prod-eastus \ --resource-group rg-ai-prod \ --resource-type "Microsoft.CognitiveServices/accounts" \ --query "properties.disableLocalAuth" # Expected: true
This will break the Azure OpenAI Studio / Foundry portal for anyone without an RBAC role

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.

Use Azure Policy to make this durable — Audit first, Deny later

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.

09Anti-Patterns: Migrations That Look Done and Aren'tTraps

Because the individual steps are simple, teams often declare victory before the migration is actually complete. These are the specific ways that happens.

Anti-patternWhy it feels rightWhy 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.

Step 1 — Prove the managed identity can actually authenticate# Run this FROM the compute resource itself (e.g. via App Service SSH, # or a temporary diagnostic endpoint) before touching disableLocalAuth. python3 -c " from azure.identity import DefaultAzureCredential cred = DefaultAzureCredential() token = cred.get_token('https://cognitiveservices.azure.com/.default') print('Token acquired:', bool(token.token)) print('Expires in:', token.expires_on) " # PASS: Token acquired: True # FAIL: an exception naming which credential source failed — usually means # the managed identity isn't provisioned yet, or the role hasn't # propagated (wait up to 5 minutes and retry).
Step 2 — Query Azure Monitor for ANY remaining key-based traffic// This is the step that makes Phase 4 (disableLocalAuth) safe. Do not // proceed until this query returns zero rows for a full business cycle // (at least 7 days, to catch weekly batch jobs). AzureDiagnostics | where ResourceProvider == "MICROSOFT.COGNITIVESERVICES" | where Category == "RequestResponse" | extend authType = tostring(parse_json(properties_s).authenticationType) | where authType == "Key" or authType == "" or isnull(authType) | summarize count() by bin(TimeGenerated, 1d), CallerIPAddress | order by TimeGenerated desc // PASS: zero rows over the full observation window. // FAIL: any rows — identify the caller by IP/UA and find what it is // BEFORE disabling local auth, or that caller goes dark with no warning.
Step 3 — After disableLocalAuth, confirm keys are actually rejected# Deliberately try the OLD key. It should fail — this is the proof the # fix is real, not just configured. curl -s -o /dev/null -w "%{http_code}\n" \ -H "api-key: $OLD_API_KEY" \ "https://aoai-prod-eastus.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21" \ -d '{"messages":[{"role":"user","content":"ping"}],"max_tokens":5}' # PASS: 401 Unauthorized — the key is now inert, regardless of validity. # FAIL: 200 OK — disableLocalAuth did not take effect. Re-check the # resource property directly (Section 8) before assuming this is fixed. # And confirm the token path still works, side by side: python3 -c " from openai import AzureOpenAI from azure.identity import DefaultAzureCredential, get_bearer_token_provider cred = DefaultAzureCredential() tp = get_bearer_token_provider(cred, 'https://cognitiveservices.azure.com/.default') client = AzureOpenAI(azure_ad_token_provider=tp, api_version='2024-10-21', azure_endpoint='https://aoai-prod-eastus.openai.azure.com/') r = client.chat.completions.create(model='gpt-4o', messages=[{'role':'user','content':'ping'}], max_tokens=5) print('Token auth works:', bool(r.choices)) " # PASS: Token auth works: True
Step 4 — Audit every role assignment on the resource for listkeys exposure# List every principal with any role on the resource, then check each # role definition for the listkeys data action. az role assignment list \ --scope "/subscriptions/$SUB_ID/resourceGroups/rg-ai-prod/providers/Microsoft.CognitiveServices/accounts/aoai-prod-eastus" \ --query "[].{principal:principalName, role:roleDefinitionName}" -o table # For each distinct role shown, confirm it does NOT include listkeys: az role definition list --name "<role name>" \ --query "[].permissions[].actions" -o tsv | grep -i listkeys # PASS: no output from the grep — listkeys is not present in any assigned role. # FAIL: any match — identify which principal holds that role and downgrade it.
What "fixed" actually means here

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

Rotation treats the symptom; removing the key removes the disease. A leaked static key is a class of incident. disableLocalAuth=true makes that class structurally impossible, not just harder.
Use Cognitive Services OpenAI User for applications, not Contributor or Owner. Neither built-in OpenAI role can call listkeys — that's the property that makes least privilege actually mean something here.
Audit every role assignment for the listkeys data action. A broader role granted "to keep things simple" can silently undo the entire migration if that identity is ever compromised.
DefaultAzureCredential needs zero environment branching. The same code authenticates via CLI login locally and managed identity in Azure. One code path, not two.
Scope role assignments to the resource, not the resource group. An application identity's blast radius should be exactly the one Azure OpenAI resource it needs, nothing adjacent.
The migration has four phases, and disableLocalAuth is always last. Provision identity, swap code, verify zero remaining key traffic for a full week, then — and only then — disable local auth.
After disabling local auth, deliberately test the old key. A 401 on a known-valid key is the only proof the fix is real. "It's configured" and "it's enforced" are different claims — verify the second one.

Frequently Asked Questions

What's the difference between Cognitive Services OpenAI User and Cognitive Services OpenAI Contributor?
Cognitive Services OpenAI User grants inference only — chat completions, embeddings, image generation, and read access to models, deployments, and files. It is the role to assign to a production application's managed identity. Cognitive Services OpenAI Contributor includes everything in OpenAI User plus the ability to create and edit model deployments, run fine-tuning jobs, upload training data, and configure the Assistants API — capabilities a platform or ML engineer needs, but that a runtime application identity generally shouldn't have. Critically, neither role can view or regenerate the resource's API keys — that permission, Microsoft.CognitiveServices/accounts/listkeys/action, is deliberately absent from both, which is exactly what makes them safe defaults for least-privilege access.
If I disable local auth, will my Azure OpenAI Studio / Foundry portal access break?
It depends entirely on whether your signed-in account has an RBAC role assigned on the resource. Historically, the Studio/Foundry portal experience used API keys under the hood for some operations. With disableLocalAuth=true, portal access depends completely on your Entra ID identity having Cognitive Services OpenAI User or a broader role on that specific resource. If you skip this step, portal features like the chat playground or deployment management will appear to silently fail for anyone without an assigned role — with no obvious error message pointing at "you need RBAC access." Assign roles to every human who needs portal access before disabling local auth, not after.
Can I use DefaultAzureCredential the same way locally and in production?
Yes, and that consistency is the main practical benefit of the approach. DefaultAzureCredential tries a sequence of credential sources in order — environment variables, workload identity, managed identity, then developer tools like the Azure CLI or Visual Studio — and uses whichever one succeeds in the current environment. On your laptop, after an az login, it picks up your own Entra ID user session automatically. In Azure, on a resource with a managed identity assigned, it uses that identity instead. Your application code calls the exact same constructor either way; there's no if os.environ.get("ENVIRONMENT") == "production" branch needed anywhere.
How do I know when it's actually safe to disable local auth, not just probably safe?
Query Azure Monitor diagnostic logs for the Azure OpenAI resource, filtering for requests authenticated via key rather than Entra ID token, over a window of at least seven days — long enough to catch weekly or scheduled batch jobs that a shorter window would miss. If that query returns zero rows for the full window, every caller you can observe has migrated. Then disable local auth and immediately run a deliberate test with the old key — it should return 401. That test is the actual proof; a clean audit log tells you nothing was observed using the key, but the 401 test proves the key can no longer work even if some caller you didn't know about tries it later.

Popular posts from this blog

Learn how to use Azure Chaos Studio to simulate data center outages, test Azure OpenAI failover, and validate AI app resiliency using KQL and CLI workflows

Resiliency Testing Chaos Studio Zone Down Azure OpenAI Failover Testing AI Resiliency: Using Azure Chaos Studio to Simulate Data Center Outages on Your LLM Every multi-region Azure OpenAI architecture diagram has a failover arrow drawn on it. Almost none of them have ever actually been triggered. The arrow is a hypothesis, confirmed only by a real outage — unless you deliberately cause a controlled one first, on your own schedule, with a rollback plan, instead of finding out during an incident that the failover you designed never quite worked the way the diagram promised. The failure signature this guide resolves # The gap this article closes — a real architecture review finding: Design doc, page 4: "In the event of a regional outage, Azure Front Door automatically routes traffic to the secondary Azure OpenAI deployment in West Europe, with an expected failover time under 60 seconds." Verification performed to support this claim: NONE. Last time this path was ac...

Improve AI application performance by reducing latency, optimizing embeddings, and lowering cloud inference costs

Performance Fix Foundry Local 1.2 Linux ARM64 Embeddings Offline ASR The Edge Latency Drop: Fixing Latency Spikes by Offloading Embeddings to Foundry Local 1.2 You are paying a full cloud round trip — network, TLS, queue, throttle risk — to turn a twelve-word search query into a vector. That is the most expensive way possible to do one of the cheapest computations in your stack. Foundry Local 1.2 now runs on Linux ARM64, which means embeddings and speech recognition can happen on a Raspberry Pi, a Jetson, or a Graviton instance — offline, unmetered, and in single-digit milliseconds. The failure signature this guide resolves # Application Insights — the embedding call, not the LLM, is your tail latency: name p50 p95 p99 calls/day POST /embeddings (cloud) 89 ms 412 ms 3,847 ms 1,240,000 POST /chat/completions (cloud) 940 ms 1,720 ms 2,910 ms 38,000 ^^^^^^^^ ...

Learn how to select Azure Files and Blob storage tiers, avoid early deletion fees, model costs, and automate lifecycle management for large file migrations.

Choosing the Right Azure Storage Tier for Large File Migrations The complete decision framework for storage tier selection during large file migrations — Azure Files tiers, Blob tiers, cost modelling, early deletion traps, lifecycle automation, and the 2026 changes that affect every migration running today. By Francis Avorgbedor | Azure Engineer  ·  July 14, 2026  ·  18 min read  ·  Storage Tiers · Cost Optimisation · Migration FA Francis Avorgbedor Azure Engineer  ·  SEVENAI  ·  Azure Field Notes 9 Distinct Azure storage tiers across Files and Blob — most engineers know only 3 15hrs Archive tier rehydration time at standard priority — the delay teams forget to plan for 128KB Minimum billable object size for Cool/Cold/Archive from July 2026 — a 32× trap for small files 70% How much retrieval and transaction fees add to a theoretical Archive storage bill The most expensive mistake I see on large Azure file migrations is not choosing the w...

Find the hidden Windows 10 system files consuming up to 500GB, including hibernation, shadow copies, backups, and WinSxS, with safe cleanup steps.

  The 500GB System File That Eats Your Hard Drive Something on your Windows 10 drive is consuming hundreds of gigabytes and the normal tools cannot find it. This guide identifies every known culprit — from hibernation files and shadow copies to runaway backups and the Windows component store — and tells you exactly what is safe to delete, what to leave alone, and what the commands actually do.

Learn safe methods to reset Azure virtual machines using managed disks while preserving critical workloads

How to Reset an Azure Virtual Machine to Factory Settings Using a Managed Disk Azure does not have a single "factory reset" button. What it does have is something better: the OS Disk Swap — a method that swaps out the corrupted or misconfigured OS disk for a clean Windows Server managed disk without deleting the VM, its NICs, its IP addresses, or any attached data disks. Here is how it works, when to use it, and the exact steps to execute it safely. FA Francis Avorgbedor Azure Engineer July 16, 2026 15 min read Azure VMs · Windows Server · Real-World Fix 3 Methods to achieve a clean Windows Server installation on an existing Azure VM ~15min Typical OS Disk Swap duration — VM retains its NICs, IPs, and data disks throughout 0 Data disks affected by an OS Disk Swap — data disks remain attached and untouched 1 Snapshot of the original OS disk you must take before starting — no exceptions Introduction Why Azure Does Not Have a Simple Factory Reset — and What to Do Instead On a ph...

Determine Windows 11 compatibility, upgrade requirements, costs, and performance expectations on older hardware

Can I Update My Old Computer to Windows 11 — and How Much Will It Cost? Your i7, 16GB RAM, 512GB SSD machine is powerful enough to run Windows 11 comfortably. The TPM 2.0 and Secure Boot wall is a security checkbox, not a performance ceiling. Here are two proven ways to get past it, what each one costs, and what you are trading away by doing so. $0 Cost of the Windows 11 licence if your existing Windows 10 is genuine — the upgrade remains free in 2026 2 Proven methods to bypass TPM 2.0 and Secure Boot — Rufus (easy) and Registry edit (manual) 25H2 Current Windows 11 version — all known bypass methods tested and confirmed working as of July 2026 Oct 2025 Windows 10 end of life — no more security updates. Staying on Windows 10 now carries real risk. First — Check Your BIOS Before Anything Else You Might Not Actually Need a Bypass Before running any bypass, open your BIOS and look at two settings. Many computers that fail the Windows 11 compatibility check have TPM 2.0 present in the hard...

Solve common AKS issues with practical troubleshooting techniques for networking, scaling, upgrades, and workloads

Troubleshooting Guide AKS Kubernetes Real Solutions kubectl Azure Kubernetes Service (AKS) Troubleshooting Guide: Real Solutions to Common Problems CrashLoopBackOff at 2am. Pods stuck Pending with no obvious cause. Nodes going NotReady mid-deployment. DNS resolution silently failing in production. Every AKS engineer encounters these — the difference between engineers who panic and engineers who stay calm is knowing the exact sequence of diagnostic commands to run. This guide gives you that sequence, the root cause analysis for each failure mode, and the fix. 3 commands 90% of AKS problems are diagnosed with the same three kubectl commands: describe pod, logs --previous, and get events — in that order, every time Exit 137 The exit code that tells you everything: container killed by SIGKILL — either the Linux OOM killer (memory limit exceeded) or kubelet after grace period expired 5 min The CrashLoopBackOff ceiling: Kubernetes applies exponential backoff (10s → 20s → 40s → 80s → 160s → 3...

Step-by-step guide to deploying scalable AI chatbots on Azure with OpenAI and App Service

Step-by-Step Guide Azure OpenAI App Service Production Python How to Deploy an AI Chatbot on Azure Using Azure OpenAI and App Service From zero to a production-grade AI chatbot: provision Azure OpenAI, write a streaming Flask API backend, deploy it on Azure App Service with Managed Identity, wire in conversation history and content safety, and instrument it with Application Insights — all with complete code and Terraform IaC. No API keys in environment variables. No hardcoded secrets. No half-finished PoC patterns. 7 phases This guide covers the full deployment lifecycle: architecture design → resource provisioning → backend code → App Service deployment → streaming → security → monitoring Zero keys The chatbot authenticates to Azure OpenAI using Managed Identity and DefaultAzureCredential — no API keys stored in environment variables, Key Vault, or code SSE Server-Sent Events stream GPT tokens to the browser as they generate — the same token-by-token typing effect users expect from pr...