Every connection string in your appsettings.json is a liability. It can leak through a git commit, a log line, or a screenshot; it has to be rotated on a schedule nobody actually follows; and it works equally well for an attacker as it does for your app. Managed identity is Azure's answer, and once we adopted it, we stopped storing credentials for Azure services entirely.
The problem with secrets
Most .NET applications talk to a database, a storage account, maybe a Key Vault, and a message broker. Traditionally each of those means a secret: a connection string with an embedded password, an account key, an API token. Those secrets sprawl across appsettings files, CI/CD variable groups, developer machines, and Slack messages when someone needs "just the staging one quickly".
The operational cost is real. Keys need rotation, and rotation means coordinated deployments. A leaked storage account key grants full access to every blob in the account until someone notices and regenerates it — which then breaks every other app using the same key. GitHub's secret scanning finds thousands of live Azure credentials in public repos every month, and those are just the public ones.
The root issue is that a secret is a bearer credential: whoever holds it, is it. Managed identity removes the bearer credential from the picture. Your code never sees a password because there isn't one.
What is a managed identity in Azure?
A managed identity is an identity in Microsoft Entra ID that Azure creates and manages for a compute resource — an App Service, a VM, a Function app, a container app. The platform handles the credential lifecycle internally, so your code requests tokens from a local endpoint instead of presenting a secret. You grant that identity RBAC roles on other Azure resources, and your app authenticates to them with short-lived Entra tokens that never appear in your configuration.
System-assigned vs user-assigned
There are two flavors, and the choice matters more than the docs suggest.
| Aspect | System-assigned | User-assigned |
|---|---|---|
| Lifecycle | Tied to the resource; deleted with it | Standalone resource; survives independently |
| Sharing | One resource only | Shared across many resources |
| Role assignments | Re-created (and re-granted) if resource is recreated | Stable across infra rebuilds |
| Setup | One flag on the resource | Create identity, then assign it |
| Typical use | Single app, simple setup | Blue/green slots, fleets of apps, IaC pipelines |
System-assigned is the low-friction default: flip a switch and the identity exists. Its weakness shows up with infrastructure-as-code — destroy and recreate the App Service and you get a new principal ID, and every role assignment has to be re-granted. User-assigned identities are separate resources, so the identity and its role assignments survive a rebuild. For anything managed by Bicep or Terraform, we default to user-assigned.
Enabling it on App Service
For a system-assigned identity, one command:
az webapp identity assign \
--name geeksarray-api \
--resource-group rg-geeksarray
# Output includes the principalId — save it, you need it for role grants
For user-assigned, create the identity first, then attach it:
az identity create --name id-geeksarray-api --resource-group rg-geeksarray
az webapp identity assign \
--name geeksarray-api \
--resource-group rg-geeksarray \
--identities /subscriptions/<sub-id>/resourcegroups/rg-geeksarray/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-geeksarray-api
The identity existing is only half the story. It has no permissions until we grant them.
Granting roles with RBAC
Azure data-plane access uses built-in roles scoped to a resource. Two we use constantly:
# Read secrets from Key Vault (RBAC-enabled vault)
az role assignment create \
--assignee <principal-id> \
--role "Key Vault Secrets User" \
--scope /subscriptions/<sub-id>/resourceGroups/rg-geeksarray/providers/Microsoft.KeyVault/vaults/kv-geeksarray
# Read and write blobs
az role assignment create \
--assignee <principal-id> \
--role "Storage Blob Data Contributor" \
--scope /subscriptions/<sub-id>/resourceGroups/rg-geeksarray/providers/Microsoft.Storage/storageAccounts/stgeeksarray
Grant the narrowest role that works. "Secrets User" reads secrets; it cannot list keys or manage the vault. "Storage Blob Data Contributor" touches blob data but not account configuration. Note that being Owner of the subscription does not give you data-plane access to blobs — control plane and data plane are separate, which surprises everyone once.
Role assignments take a minute or two to propagate. If your first request after granting fails with 403, wait and retry before assuming you did it wrong.
DefaultAzureCredential: same code everywhere
The magic that makes this pleasant is DefaultAzureCredential from the Azure.Identity package. It tries a chain of credential sources in order until one works:
- Environment variables (
AZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_CLIENT_SECRET) — for CI or explicit service principals. - Workload identity — for AKS.
- Managed identity — when running on Azure compute.
- Azure CLI (
az login) — on your development machine. - Visual Studio / VS Code sign-in.
The consequence: the exact same code authenticates via managed identity in production and via your az login session locally. No #if DEBUG, no fake local secrets.
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
var credential = new DefaultAzureCredential();
var client = new SecretClient(
new Uri("https://kv-geeksarray.vault.azure.net/"),
credential);
KeyVaultSecret secret = await client.GetSecretAsync("SendGridApiKey");
Console.WriteLine($"Retrieved secret, version {secret.Properties.Version}");
If you use a user-assigned identity, tell the credential which one, because a resource can have several:
var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
ManagedIdentityClientId = builder.Configuration["ManagedIdentityClientId"]
});
That client ID is not a secret — it is just an identifier, safe to keep in appsettings.
Azure SQL without a password
SQL Server is where connection strings feel most entrenched, and it is also fully covered. Microsoft.Data.SqlClient supports Entra authentication natively:
// appsettings.json — no password anywhere
// "ConnectionStrings": {
// "Default": "Server=tcp:sql-geeksarray.database.windows.net,1433;Database=geeksarray;Authentication=Active Directory Default;Encrypt=True;"
// }
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
Authentication=Active Directory Default uses the same DefaultAzureCredential chain under the hood. Two prerequisites: the server needs an Entra admin configured, and the identity needs a contained database user:
CREATE USER [geeksarray-api] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [geeksarray-api];
ALTER ROLE db_datawriter ADD MEMBER [geeksarray-api];
The user name is the App Service name for system-assigned identities, or the identity's name for user-assigned. From that point on, the "connection string" in config contains nothing worth stealing.
The local development story
Locally there is no managed identity endpoint, so the credential chain falls through to the Azure CLI. Run az login once, and grant your own user account the same RBAC roles you granted the app — your email works as the --assignee. Now dotnet run authenticates as you, in production the app authenticates as itself, and the codebase is identical.
For services with no Entra support — a third-party SendGrid key, a Stripe secret — the pattern composes: put those secrets in Key Vault, and use managed identity to read them. The vault becomes the only place secrets live, with access logging and versioning for free, and your app config holds nothing but URLs and identifiers.
Two sharp edges worth knowing. First, DefaultAzureCredential's probing adds a second or two on first token acquisition locally; tokens are cached after that. Second, if you are logged into multiple tenants, set the right one with az account set --subscription <sub-id> or you will chase confusing 401s. The working sample for this post is in the 04-managed-identity folder of the companion repo at github.com/laxmikant-geek/azure-for-dotnet-examples.
What's next: with credentials out of the way, we can talk to real services cleanly. In Part 5 we dig into Azure Storage from C# — blobs, queues, access tiers, and how to issue SAS tokens that actually expire instead of living forever in someone's download folder.
Comments (0)
No comments yet — be the first to share your thoughts.