Managed Identities in Azure: Stop Storing Secrets
Most security incidents involving cloud credentials do not start with a clever exploit. They start with a connection string committed to a repository, a client secret pasted into a pipeline variable that nobody rotates, or an appsettings.Production.json that ended up in a container image. Every secret you store is a secret you have to protect, rotate, and eventually leak.
Managed identities remove that whole category of problem for Azure-to-Azure communication. Azure issues and rotates the credential for you, your code asks for a token at runtime, and access is controlled by role assignments rather than shared keys. This post covers how to adopt them properly in .NET workloads, including the parts that tend to trip teams up.
What a managed identity actually is
A managed identity is a service principal in Microsoft Entra ID whose lifecycle and credentials are managed by Azure. You never see a password or certificate. When your code runs on a supported compute resource (App Service, Azure Functions, Container Apps, AKS with workload identity, VMs, and others), it requests an access token from a local endpoint exposed by the platform. The token is then presented to the target service, such as Key Vault, Storage, Service Bus, or Azure SQL, which validates it against Entra ID.
Two things have to be true for a call to succeed:
- The compute resource has an identity.
- That identity has been granted a role on the target resource.
Authentication is handled by the platform. Authorization is your job, through Azure RBAC or the target service's own permission model.
System-assigned vs user-assigned
There are two flavors, and choosing between them is mostly a lifecycle decision.
| Aspect | System-assigned | User-assigned |
|---|---|---|
| Lifecycle | Tied to one resource; deleted with it | Standalone Azure resource |
| Sharing | One resource only | Can be attached to many resources |
| Role assignments | Recreated whenever the resource is recreated | Survive resource replacement |
| Typical use | Single app with its own permissions | Fleets, slots, blue/green, pre-provisioned access |
| Selection in code | Default identity | Must specify client ID when more than one exists |
System-assigned identities are simple and fine for a single app. User-assigned identities shine when you recreate infrastructure often, when several instances need identical permissions, or when you want role assignments to exist before the app is deployed. In infrastructure-as-code setups I lean toward user-assigned identities because they decouple permissions from the compute lifecycle.
Enabling an identity with the Azure CLI
Enabling a system-assigned identity on a web app is a single command:
az webapp identity assign \
--resource-group rg-orders-prod \
--name app-orders-prod
The output contains the principalId, which is what you grant roles to. For a user-assigned identity, create it first and then attach it:
az identity create \
--resource-group rg-orders-prod \
--name id-orders-api
az webapp identity assign \
--resource-group rg-orders-prod \
--name app-orders-prod \
--identities /subscriptions/<sub-id>/resourceGroups/rg-orders-prod/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-orders-api
Granting access with role assignments
An identity without roles can authenticate but cannot do anything. Grant the narrowest data-plane role at the narrowest scope that works:
PRINCIPAL_ID=$(az identity show -g rg-orders-prod -n id-orders-api --query principalId -o tsv)
STORAGE_ID=$(az storage account show -g rg-orders-prod -n stordersprod --query id -o tsv)
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Contributor" \
--scope "$STORAGE_ID/blobServices/default/containers/invoices"
Two details matter here. First, use --assignee-object-id with --assignee-principal-type ServicePrincipal; it avoids a Graph lookup that can fail right after the identity is created. Second, note the difference between control-plane roles like Contributor and data-plane roles like Storage Blob Data Contributor. Contributor on a storage account does not let your code read blobs through Entra ID authentication, and it grants far more than you want.
The same thing in Bicep
In Bicep, give role assignments a deterministic name with guid() so redeployments are idempotent:
param storageAccountName string
param identityName string
var blobDataContributorRoleId = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'
resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: identityName
location: resourceGroup().location
}
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
name: storageAccountName
}
resource blobAccess 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storage.id, identity.id, blobDataContributorRoleId)
scope: storage
properties: {
principalId: identity.properties.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', blobDataContributorRoleId)
}
}
Using DefaultAzureCredential in .NET
The Azure.Identity package provides DefaultAzureCredential, which tries a chain of credential sources in order: environment variables, workload identity, managed identity, then developer tools such as Visual Studio, the Azure CLI, Azure PowerShell, and the Azure Developer CLI. The same code runs locally with your developer login and in Azure with the managed identity.
using Azure.Identity;
using Azure.Storage.Blobs;
using Microsoft.Extensions.Azure;
var builder = WebApplication.CreateBuilder(args);
var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
// Required when the app has more than one user-assigned identity
ManagedIdentityClientId = builder.Configuration["AZURE_CLIENT_ID"]
});
builder.Services.AddAzureClients(clients =>
{
clients.AddBlobServiceClient(new Uri("https://stordersprod.blob.core.windows.net"));
clients.AddSecretClient(new Uri("https://kv-orders-prod.vault.azure.net"));
clients.UseCredential(credential);
});
Notice there is no key or connection string anywhere, only service endpoints, which are not secrets.
Be explicit in production
DefaultAzureCredential is convenient but it probes several sources, which can add latency on failure and makes behavior depend on the environment. A common production pattern is to use ManagedIdentityCredential in Azure and fall back to developer credentials only locally:
TokenCredential credential = builder.Environment.IsDevelopment()
? new AzureCliCredential()
: new ManagedIdentityCredential(builder.Configuration["AZURE_CLIENT_ID"]);
This makes failures loud and specific instead of surfacing as a long chained exception message.
Key Vault references and remaining secrets
Some secrets are unavoidable, like third-party API keys. Put them in Key Vault, grant the identity the Key Vault Secrets User role, and let App Service or Functions resolve them through Key Vault references:
az webapp config appsettings set \
--resource-group rg-orders-prod \
--name app-orders-prod \
--settings PaymentApiKey="@Microsoft.KeyVault(SecretUri=https://kv-orders-prod.vault.azure.net/secrets/payment-api-key/)"
If you use a user-assigned identity for this, set the app's keyVaultReferenceIdentity property to that identity; otherwise the platform uses the system-assigned identity. Omitting the secret version lets the platform pick up rotated values, though not instantly, so check the documented refresh behavior before relying on it for fast rotation.
Connecting to Azure SQL without passwords
Azure SQL supports Entra ID authentication, and Microsoft.Data.SqlClient can acquire tokens itself. Create a database user mapped to the identity, running this as an Entra admin:
CREATE USER [id-orders-api] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [id-orders-api];
ALTER ROLE db_datawriter ADD MEMBER [id-orders-api];
Then the connection string carries no password:
{
"ConnectionStrings": {
"Orders": "Server=tcp:sql-orders-prod.database.windows.net,1433;Database=orders;Authentication=Active Directory Default;Encrypt=True;"
}
}
Active Directory Default uses a credential chain similar to DefaultAzureCredential. For a user-assigned identity, use Authentication=Active Directory Managed Identity and add User Id=<client-id>. Entity Framework Core works unchanged on top of this.
Removing secrets from CI/CD with workload identity federation
Pipelines are the last place long-lived secrets tend to hide. With workload identity federation, GitHub Actions or Azure DevOps exchanges its own OIDC token for an Entra token, with no client secret stored anywhere:
az identity federated-credential create \
--name github-main \
--identity-name id-deploy \
--resource-group rg-platform \
--issuer https://token.actions.githubusercontent.com \
--subject repo:my-org/orders-api:ref:refs/heads/main \
--audiences api://AzureADTokenExchange
permissions:
id-token: write
contents: read
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
The subject claim is the security boundary. Scope it to a branch or environment rather than trusting every workflow in the repository.
Pitfalls I see in production
Role assignment propagation
New role assignments are not always effective immediately, and tokens are cached on the client side. A deployment that creates an identity, assigns a role, and immediately starts the app may see 403 errors for a few minutes. Build retries into startup health checks or order your deployment so roles are assigned before traffic arrives.
Multiple user-assigned identities
If a resource has more than one user-assigned identity and you do not specify a client ID, token acquisition fails or picks the wrong identity. Always pass the client ID explicitly, typically through an AZURE_CLIENT_ID app setting.
Keys left enabled
Moving the app to managed identity does not remove the old keys. Once migrated, disable shared key access where the service supports it, for example with az storage account update --allow-shared-key-access false, and disable local authentication on Service Bus or Cosmos DB. Otherwise the leaked connection string from last year still works.
Over-broad scope
Assigning Owner or Contributor at the subscription level because it "just works" defeats the purpose. Keep data-plane roles at resource or container scope and review assignments periodically.
Key takeaways
- Managed identities remove credential storage and rotation for Azure-to-Azure calls.
- Prefer user-assigned identities when infrastructure is recreated often or shared across instances.
- Grant data-plane roles at the narrowest scope, and assign them before the app needs them.
- Use
DefaultAzureCredentialfor convenience, but consider explicit credentials in production. - Close the loop: disable shared keys and replace pipeline secrets with federated credentials.
Conclusion
The goal is not just to hide secrets better; it is to stop having them. Managed identities, RBAC, Key Vault references for the few external secrets you still need, and workload identity federation for pipelines together cover almost every credential a typical .NET system on Azure depends on. The migration is incremental and low-risk: enable the identity, grant roles, switch the client construction, then disable the keys. Each step removes a secret you would otherwise have to protect forever.