Managed Identities in Azure: Stop Storing Secrets

TQ
Tran Quang
September 18, 2026 · 8 min read
#Azure#Managed Identity#Security#.NET

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:

  1. The compute resource has an identity.
  2. 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:

bash
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:

bash
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:

bash
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:

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

csharp
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:

csharp
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:

bash
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:

sql
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:

json
{
  "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:

bash
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
yaml
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 DefaultAzureCredential for 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.

Found this useful?

Share it with someone who might need it.

Related articles All articles