RO EN

Managed Identity — eliminates secrets from the configuration

Managed Identity — eliminates secrets from the configuration
Doru Bulubașa
22 June 2026
42 views

The Problem We All Ignore

Open any .NET project older than a few years and, most likely, you will find something like this in appsettings.json or in environment variables:

{
  "CosmosDb": {
    "Endpoint": "https://myaccount.documents.azure.com:443/",
    "AccountKey": "dGhpcyBpcyBub3QgYSByZWFsIGtleQ=="
  },
  "Storage": {
    "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=mystorage;AccountKey=abc123..."
  },
  "OpenAI": {
    "ApiKey": "sk-proj-..."
  }
}

The problem is not that it works. It works perfectly. The problem is everything that comes with this approach:

  • Key rotation is manual and painful — if an AccountKey expires or is compromised, you have to change it everywhere it is stored, redeploy, and hope you didn't forget anywhere
  • Secrets end up in Git — a careless git add . and the credentials are in the repository history forever
  • The attack surface is large — anyone with access to the app configuration (a new colleague, a contractor, a leaked log file) has access to all resources
  • Audit is impossible — you cannot know who used the key and when; all operations appear under the same identity

There is a better, native Azure alternative that eliminates all these problems: Managed Identity.


What is Managed Identity

Managed Identity is a mechanism by which Azure assigns an identity (a service principal in Azure Active Directory) directly to your resource — App Service, Azure Functions, Container Apps, VM, etc. This identity can receive RBAC roles on other Azure resources, just like any user or application.

The difference compared to a classic service principal: you don't manage any credentials. There is no client secret, no certificate, no key to rotate. Azure internally manages authentication tokens. Your application requests a token, Azure provides it, resources accept it.

Approach Secret storage Rotation Per-identity audit
Connection string / API key appsettings, env vars, Key Vault Manual, disruptive No
Service principal with secret Key Vault (client secret) Manual, periodic Yes, but shared
Managed Identity Nowhere Automatic, Azure handles it Yes, per resource

There are two variants:

  • System-assigned — the identity is tied to the lifecycle of the resource; it is deleted automatically when you delete the App Service. Ideal for resources with a single purpose.
  • User-assigned — the identity is a separate resource, can be assigned to multiple Azure resources. Ideal when multiple App Services or Functions need to access the same resources.

Enabling Managed Identity

Azure Portal

App Service → Identity → System assigned → Status: On → Save. That's it. Azure automatically creates a service principal with the same name as your App Service.

Azure CLI

# System-assigned identity for App Service
az webapp identity assign \
  --name my-app-service \
  --resource-group my-rg

# Result -- keep principalId for next step
# {
#   "principalId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
#   "tenantId": "yyyyyyyy-...
# }

# System-assigned for Azure Functions
az functionapp identity assign \
  --name my-function-app \
  --resource-group my-rg

Bicep / IaC

resource appService "Microsoft.Web/sites@2023-01-01" = {
  name: appServiceName
  location: location
  identity: {
    type: "SystemAssigned"  // enable managed identity
  }
  properties: {
    serverFarmId: appServicePlan.id
    // ...
  }
}

DefaultAzureCredential — one code for all environments

The Azure.Identity package provides DefaultAzureCredential, which tries several authentication methods in order, selecting the first that works:

dotnet add package Azure.Identity

The authentication chain in order:

  1. EnvironmentCredential — environment variables (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
  2. WorkloadIdentityCredential — Kubernetes workload identity
  3. ManagedIdentityCredential — Managed Identity in Azure (App Service, Functions, Container Apps, VM)
  4. SharedTokenCacheCredential — local token cache
  5. VisualStudioCredential — logged-in account in Visual Studio
  6. AzureCliCredential — logged-in account via az login
  7. AzurePowerShellCredential — logged-in account in PowerShell
  8. InteractiveBrowserCredential — browser (disabled by default)

Basically: on your development machine, it authenticates via az login or Visual Studio. In Azure, it automatically uses Managed Identity. The same code works in both environments without any changes.

// One line -- works locally (az login) and in Azure (Managed Identity)
var credential = new DefaultAzureCredential();

If you want finer control and want to avoid timeouts locally (skip slow methods), you can configure explicitly:

// Local: try only az login and Visual Studio
// In Azure: try only Managed Identity
var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
    ExcludeEnvironmentCredential      = true,
    ExcludeWorkloadIdentityCredential = true,
    ExcludeSharedTokenCacheCredential = true,
    ExcludeInteractiveBrowserCredential = true
});

Access to Cosmos DB without AccountKey

Step 1: assign RBAC role on Cosmos DB

Cosmos DB has built-in roles for data plane: Cosmos DB Built-in Data Reader and Cosmos DB Built-in Data Contributor. Assign the appropriate role to your application's identity:

# Get principalId of the App Service
PRINCIPAL_ID=$(az webapp identity show \
  --name my-app-service \
  --resource-group my-rg \
  --query principalId -o tsv)

COSMOS_ACCOUNT_ID=$(az cosmosdb show \
  --name my-cosmos-account \
  --resource-group my-rg \
  --query id -o tsv)

# Assign Cosmos DB Built-in Data Contributor
az cosmosdb sql role assignment create \
  --account-name my-cosmos-account \
  --resource-group my-rg \
  --role-definition-name "Cosmos DB Built-in Data Contributor" \
  --principal-id $PRINCIPAL_ID \
  --scope $COSMOS_ACCOUNT_ID

Step 2: configure CosmosClient without key

// Program.cs -- no AccountKey, no connection string
builder.Services.AddSingleton(sp =>
{
    var endpoint = builder.Configuration["CosmosDb:Endpoint"]
        ?? throw new InvalidOperationException("CosmosDb:Endpoint is not configured");

    return new CosmosClient(
        accountEndpoint: endpoint,
        tokenCredential: new DefaultAzureCredential(),
        clientOptions: new CosmosClientOptions
        {
            SerializerOptions = new CosmosSerializationOptions
            {
                PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
            }
        }
    );
});

In appsettings.json only the endpoint remains — which is not a secret:

{
  "CosmosDb": {
    "Endpoint": "https://my-cosmos-account.documents.azure.com:443/",
    "DatabaseName": "MyAppDb"
  }
}

The AccountKey has completely disappeared from the configuration.


Access to Blob Storage without connection string

Step 1: assign RBAC role on Storage Account

STORAGE_ID=$(az storage account show \
  --name mystorageaccount \
  --resource-group my-rg \
  --query id -o tsv)

# Storage Blob Data Contributor -- read + write
az role assignment create \
  --assignee $PRINCIPAL_ID \
  --role "Storage Blob Data Contributor" \
  --scope $STORAGE_ID

# Or Storage Blob Data Reader -- read only
az role assignment create \
  --assignee $PRINCIPAL_ID \
  --role "Storage Blob Data Reader" \
  --scope $STORAGE_ID

Step 2: configure BlobServiceClient without connection string

// Program.cs
builder.Services.AddSingleton(sp =>
{
    var uri = new Uri(builder.Configuration["Storage:ServiceUri"]
        ?? throw new InvalidOperationException("Storage:ServiceUri is not configured"));

    return new BlobServiceClient(uri, new DefaultAzureCredential());
});

// appsettings.json -- only the URI, no secret
// "Storage": { "ServiceUri": "https://mystorageaccount.blob.core.windows.net" }

If you work with individual containers:

public class BlobRepository
{
    private readonly BlobContainerClient _container;

    public BlobRepository(IConfiguration config)
    {
        var containerUri = new Uri(
            $"{config["Storage:ServiceUri"]}/{config["Storage:ContainerName"]}");
        _container = new BlobContainerClient(containerUri, new DefaultAzureCredential());
    }

    public async Task UploadAsync(string blobName, Stream content)
    {
        await _container.UploadBlobAsync(blobName, content);
    }

    public async Task<Stream> DownloadAsync(string blobName)
    {
        var blob = _container.GetBlobClient(blobName);
        var response = await blob.DownloadStreamingAsync();
        return response.Value.Content;
    }
}

Access to Key Vault — remaining secrets

There are situations where you must store a secret: an API key for a third-party service (Stripe, SendGrid, an SMS gateway) that does not support Managed Identity. For these, Key Vault is the correct answer.

Step 1: assign role on Key Vault

KV_ID=$(az keyvault show \
  --name my-keyvault \
  --resource-group my-rg \
  --query id -o tsv)

# Key Vault Secrets User -- read only
az role assignment create \
  --assignee $PRINCIPAL_ID \
  --role "Key Vault Secrets User" \
  --scope $KV_ID

Step 2: integration with ASP.NET Core configuration system

// Program.cs -- Key Vault as configuration provider
if (builder.Environment.IsProduction())
{
    var kvUri = builder.Configuration["KeyVaultUri"]
        ?? throw new InvalidOperationException("KeyVaultUri is not configured");

    builder.Configuration.AddAzureKeyVault(
        new Uri(kvUri),
        new DefaultAzureCredential());
}

// After this line, secrets from Key Vault are accessible
// just like any other configuration value:
// builder.Configuration["Stripe--ApiKey"]  (-- = : in Key Vault naming)
// builder.Configuration["SendGrid--ApiKey"]

Key Vault naming convention: use -- (double dash) instead of : or __, because Key Vault does not accept : in secret names. So SendGrid--ApiKey in Key Vault becomes SendGrid:ApiKey in ASP.NET Core configuration.

Direct access to secrets (without configuration provider)

// Use when you need a secret in code, not in configuration
public class StripeService
{
    private readonly SecretClient _kvClient;

    public StripeService(IConfiguration config)
    {
        var kvUri = config["KeyVaultUri"]
            ?? throw new InvalidOperationException("KeyVaultUri is not configured");
        _kvClient = new SecretClient(new Uri(kvUri), new DefaultAzureCredential());
    }

    public async Task<string> GetApiKeyAsync()
    {
        var secret = await _kvClient.GetSecretAsync("Stripe--ApiKey");
        return secret.Value.Value;
    }
}

Managed Identity in Azure Functions

Azure Functions Isolated Worker works identically to App Service from the Managed Identity perspective. You enable identity, assign roles, use DefaultAzureCredential.

// Program.cs (Azure Functions Isolated Worker)
var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices((context, services) =>
    {
        // Key Vault in production
        if (context.HostingEnvironment.IsProduction())
        {
            var kvUri = context.Configuration["KeyVaultUri"];
            if (!string.IsNullOrEmpty(kvUri))
            {
                // Add Key Vault as configuration source
                // Requires: builder.Configuration at HostBuilder level
            }
        }

        // Cosmos DB without key
        var cosmosEndpoint = context.Configuration["CosmosDb:Endpoint"]!;
        services.AddSingleton(_ => new CosmosClient(
            cosmosEndpoint, new DefaultAzureCredential()));

        // Blob Storage without connection string
        var storageUri = context.Configuration["Storage:ServiceUri"]!;
        services.AddSingleton(_ => new BlobServiceClient(
            new Uri(storageUri), new DefaultAzureCredential()));
    })
    .Build();

await host.RunAsync();

An important difference for Azure Functions: configuration variables are set in Application Settings in the portal, not in local.settings.json (which is only for local development and is not committed to Git).

// local.settings.json -- ONLY for local development, not committed to Git
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "CosmosDb:Endpoint": "https://localhost:8081/"
  }
}

Local development with az login

When running locally, DefaultAzureCredential will use your Azure account logged in via Azure CLI or Visual Studio. You don't need to put any key in local files.

# One time, on the development machine
az login
az account set --subscription "My Subscription"

From now on, any application using DefaultAzureCredential will authenticate with your account. Make sure you also have the necessary roles on the respective resources (Cosmos DB Built-in Data Contributor, Storage Blob Data Contributor, etc.).

If you work with multiple team members, each receives the necessary roles individually. There is no shared secret that everyone knows.

# Assign roles for developers as well
DEV_OBJECT_ID=$(az ad user show --id developer@company.com --query id -o tsv)

az cosmosdb sql role assignment create \
  --account-name my-cosmos-account \
  --resource-group my-rg \
  --role-definition-name "Cosmos DB Built-in Data Contributor" \
  --principal-id $DEV_OBJECT_ID \
  --scope $COSMOS_ACCOUNT_ID

User-assigned Managed Identity — when it is better

System-assigned is simple but has a limitation: if you delete the App Service and recreate it (a frequent operation in IaC), the identity changes and you have to reassign all RBAC roles.

User-assigned Managed Identity is a separate resource with an independent lifecycle:

# Create user-assigned identity
az identity create \
  --name my-app-identity \
  --resource-group my-rg

IDENTITY_ID=$(az identity show \
  --name my-app-identity \
  --resource-group my-rg \
  --query id -o tsv)

PRINCIPAL_ID=$(az identity show \
  --name my-app-identity \
  --resource-group my-rg \
  --query principalId -o tsv)

# Attach identity to App Service
az webapp identity assign \
  --name my-app-service \
  --resource-group my-rg \
  --identities $IDENTITY_ID
// When using user-assigned, specify clientId
// (found in portal or via az identity show --query clientId)
var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
    ManagedIdentityClientId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
});

// Or more explicitly:
var credential = new ManagedIdentityCredential(
    clientId: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx");

Use user-assigned when: multiple Azure resources need the same set of permissions, you frequently recreate resources via IaC, or you want to pre-configure permissions before deployment.


Complete configuration without any secret

Let's recap what a clean appsettings.json looks like, without any secret:

{
  "Logging": {
    "LogLevel": { "Default": "Information" }
  },
  "KeyVaultUri": "https://my-keyvault.vault.azure.net/",
  "CosmosDb": {
    "Endpoint": "https://my-cosmos.documents.azure.com:443/",
    "DatabaseName": "MyAppDb"
  },
  "Storage": {
    "ServiceUri": "https://mystorage.blob.core.windows.net",
    "ContainerName": "uploads"
  }
}

// No AccountKey, no ConnectionString, no ApiKey.
// All these are public URIs -- not secrets.

And complete Program.cs with all three resources:

var builder = WebApplication.CreateBuilder(args);

// Key Vault in production
if (builder.Environment.IsProduction())
{
    var kvUri = builder.Configuration["KeyVaultUri"]!;
    builder.Configuration.AddAzureKeyVault(new Uri(kvUri), new DefaultAzureCredential());
}

var credential = new DefaultAzureCredential();

// Cosmos DB
builder.Services.AddSingleton(_ => new CosmosClient(
    builder.Configuration["CosmosDb:Endpoint"]!,
    credential,
    new CosmosClientOptions
    {
        SerializerOptions = new CosmosSerializationOptions
        {
            PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
        }
    }));

// Blob Storage
builder.Services.AddSingleton(_ => new BlobServiceClient(
    new Uri(builder.Configuration["Storage:ServiceUri"]!),
    credential));

// Key Vault client (for direct secret access in code)
builder.Services.AddSingleton(_ => new SecretClient(
    new Uri(builder.Configuration["KeyVaultUri"]!),
    credential));

// Rest of the app configuration...
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
await app.RunAsync();

Managed Identity Checklist

  • Managed Identity enabled — System-assigned or User-assigned on App Service / Functions
  • RBAC roles assigned — Cosmos DB Built-in Data Contributor, Storage Blob Data Contributor, Key Vault Secrets User
  • CosmosClient instantiated with DefaultAzureCredential — no AccountKey in configuration
  • BlobServiceClient instantiated with DefaultAzureCredential — no connection string in configuration
  • Key Vault for remaining secrets — third-party API keys, integrated as configuration provider
  • Local development via az login — developers receive individual roles, no shared secret
  • No secrets in Git — appsettings.json contains only endpoints, no credentials

If you have questions or want to discuss how to apply Managed Identity in your project, write to me at contact@ludoprogramming.com.