"We Store Secrets in appsettings.json": A Horror Story in Five Acts

“We Store Secrets in appsettings.json”: A Horror Story in Five Acts

“Just use a Service Principal,” they said. “Store the secret in Key Vault,” they said. So you did. And now that secret has been in your Git history since 2019, copied to three different environments, and nobody remembers which applications actually use it.

Every Azure subscription I’ve worked with contains at least a dozen connection strings with embedded credentials scattered across configuration files, Key Vault secrets that still contain passwords, and Service Principal credentials checked into Git history. The credential sprawl is real. It’s not because developers are careless. It’s because the traditional authentication patterns we learned for on-premises systems don’t translate to cloud environments.

The “create a service account, store the password somewhere secure” approach made sense when “somewhere secure” was a locked filing cabinet in the server room. In the cloud, that password ends up in CI/CD variables, Docker image layers, application logs, and that one Slack channel where someone shared the connection string “just for testing.”

Azure Managed Identity and Role-Based Access Control (RBAC) provide the solution. Not a workaround. Not a mitigation. An actual architectural pattern that makes credential leakage technically impossible for Azure resource authentication. Let’s examine why the traditional approach fails and how to implement credential-free authentication properly in .NET applications.

The Fatal Pattern: Credential Sprawl in Azure

Before we examine the correct implementation, let’s be explicit about what we’re trying to eliminate. These patterns appear in production systems daily, each representing a potential breach vector.

Service Principal Credentials in Configuration

// Fatal: Service Principal credentials in appsettings.json
{
  "AzureAd": {
    "ClientId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "ClientSecret": "P@ssw0rd123!SuperSecret",
    "TenantId": "12345678-90ab-cdef-1234-567890abcdef"
  },
  "StorageAccount": {
    "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=mystorageacct;AccountKey=abcd1234...=="
  }
}

This configuration file contains everything an attacker needs to authenticate as your application. If this file appears in your Git history, Docker image layers, or application logs, you’ve handed over the keys to your infrastructure.

I’ve lost count of how many times I’ve seen this exact pattern in production. The justification is always the same: “We need the credentials for local development” or “The deployment pipeline requires them.” Both are false. Both have better solutions. But the path of least resistance wins, and suddenly you have permanent credentials embedded in your codebase.

Storage Account Access Keys in Code

// Fatal: Hardcoded storage credentials
public class DocumentService
{
    private const string StorageAccountKey = "abcdef...==";

    public async Task UploadDocument(Stream document, string fileName)
    {
        var credentials = new StorageSharedKeyCredential("mystorageacct", StorageAccountKey);
        var blobClient = new BlobServiceClient(
            new Uri("https://mystorageacct.blob.core.windows.net"), credentials);
        // Upload logic...
    }
}

Storage account access keys provide unrestricted access to all operations on all containers. They cannot be scoped. Once leaked, the only remediation is key rotation, which breaks all other applications using that key. You’re responding to a security incident by creating an availability incident.

The irony? Storage account keys are the most commonly leaked credentials in Azure breaches. They’re also the easiest to eliminate with Managed Identity. Yet teams cling to them because “that’s how we’ve always done it.”

SQL Connection Strings with Passwords

// Fatal: SQL credentials in connection string
optionsBuilder.UseSqlServer(
    "Server=tcp:myserver.database.windows.net;Database=mydb;" +
    "User ID=sqladmin;Password=P@ssw0rd123!;Encrypt=true;");

SQL authentication credentials are typically shared across environments, can’t be rotated without updating every application instance, and audit logs show all activity as the same user account. You can’t trace actions to specific applications.

The Correct Pattern: Managed Identity and RBAC

Azure Managed Identity eliminates credentials from application code by leveraging Azure AD’s OAuth 2.0 implementation. The Azure platform manages the credential lifecycle, token acquisition, and rotation automatically.

Here’s the key insight that changes everything: instead of your application proving identity by presenting a secret (which can be stolen), Azure proves your application’s identity through platform-level cryptographic attestation. The token never leaves Azure’s infrastructure. There’s nothing to leak because there’s nothing stored in your code.

System-Assigned Managed Identity

The simplest pattern uses a system-assigned Managed Identity, which creates a service principal tied to a specific Azure resource’s lifecycle.

// App Service with system-assigned Managed Identity
resource appService 'Microsoft.Web/sites@2022-09-01' = {
  name: appServiceName
  location: location
  identity: {
    type: 'SystemAssigned'  // Creates managed identity automatically
  }
  properties: {
    serverFarmId: appServicePlan.id
    httpsOnly: true
  }
}

// RBAC: Grant least-privilege access to storage
resource storageBlobDataContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(storageAccount.id, appService.id, 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')
  scope: storageAccount
  properties: {
    // Storage Blob Data Contributor - not full Contributor!
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      'ba92f5b4-2d11-453d-a403-e96b0029c9fe')
    principalId: appService.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

The identity is automatically deleted when the App Service is deleted, preventing orphaned credentials.

Notice the RBAC assignment uses Storage Blob Data Contributor, not Contributor. This is the principle of least privilege in action. The application can read and write blobs, but it cannot delete the storage account, modify network rules, or access Table/Queue storage. If compromised, the blast radius is limited to blob operations on this specific storage account.

I see teams assign Contributor or Owner roles “because it’s easier.” Easier until the security audit. Easier until the breach. The few extra lines of Bicep are worth the reduced attack surface.

.NET Application Using DefaultAzureCredential

The Azure SDK for .NET provides DefaultAzureCredential, which implements a credential chain that works across local development and production environments without code changes.

using Azure.Identity;
using Azure.Storage.Blobs;

public class AzureResourceService
{
    private readonly BlobServiceClient _blobServiceClient;

    public AzureResourceService(IConfiguration configuration)
    {
        // DefaultAzureCredential tries in order:
        // 1. Environment variables (CI/CD)
        // 2. Workload Identity (AKS)
        // 3. Managed Identity (Azure resources)
        // 4. Azure CLI (local dev)
        var credential = new DefaultAzureCredential();

        var storageUri = new Uri(configuration["Azure:StorageAccountUri"]!);
        _blobServiceClient = new BlobServiceClient(storageUri, credential);
    }

    public async Task<byte[]> DownloadDocumentAsync(
        string containerName, string blobName, CancellationToken ct = default)
    {
        var container = _blobServiceClient.GetBlobContainerClient(containerName);
        var blob = container.GetBlobClient(blobName);
        var response = await blob.DownloadContentAsync(ct);
        return response.Value.Content.ToArray();
    }
}

Configuration (appsettings.json) - Notice: no credentials:

{
  "Azure": {
    "StorageAccountUri": "https://mystorageacct.blob.core.windows.net",
    "KeyVaultUri": "https://mykeyvault.vault.azure.net"
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=tcp:myserver.database.windows.net;Database=mydb;Encrypt=true;"
  }
}

The connection string contains no User ID or Password. Same code works locally via Azure CLI and in production via Managed Identity.

This is the beauty of DefaultAzureCredential: zero code changes between environments. No #if DEBUG blocks. No environment-specific configuration files with different credential strategies. The SDK figures out which credential type to use based on where it’s running. Your code stays clean.

Local Development Without Credential Management

One of DefaultAzureCredential’s significant advantages is enabling developers to work with Azure resources locally without managing credentials in configuration files.

Development workflow:

  1. Developer authenticates to Azure CLI: az login
  2. Application uses DefaultAzureCredential, which detects Azure CLI credentials
  3. Same code runs in production using Managed Identity
  4. No credentials in appsettings.Development.json

Developers use their individual Azure AD accounts, maintaining a proper audit trail. No shared development credentials.

SQL Database Configuration

Azure SQL requires additional configuration to enable Managed Identity authentication:

-- Execute as Azure AD admin on the database
CREATE USER [app-service-name] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [app-service-name];
ALTER ROLE db_datawriter ADD MEMBER [app-service-name];

Note: RBAC assignments can take several minutes to propagate. If you get 403 errors right after deployment, wait a few minutes or implement retry logic.

The Migration Path

If you’re starting with the fatal patterns shown earlier, here’s a pragmatic migration path:

Phase 1 - Key Vault Migration (Days): Move existing credentials to Azure Key Vault, access Key Vault using Managed Identity. This eliminates credentials from code without changing application authentication logic.

Phase 2 - Managed Identity for Azure Resources (Weeks): Convert storage, Service Bus, and other Azure resource authentication to use Managed Identity. This is the highest-value change. Most credential leaks involve storage keys.

Phase 3 - SQL Managed Identity (Weeks): Migrate SQL authentication to Managed Identity. Requires coordination with DBAs for permission configuration but eliminates SQL credentials.

Phase 4 - Custom RBAC Roles (Months): Replace built-in roles with custom role definitions scoped to minimum necessary permissions. This is optimization. The security improvement from Phase 1-3 is already substantial.

The total effort? Weeks, not months. The hardest part isn’t the technology. It’s convincing teams to abandon patterns they’ve used for years.

The Objections (And Why They Don’t Hold Up)

I’ve implemented this pattern across multiple Azure environments, and the resistance is predictable.

“How do we rotate credentials now?” You don’t. Azure manages the credential lifecycle. The operational burden decreases while security posture improves.

“But what about local development?” Solved. Azure CLI authentication. Same code, different credential provider. Developers use their individual accounts, maintaining proper audit trails.

“Our CI/CD pipeline needs credentials.” Use Workload Identity Federation for GitHub Actions or Azure DevOps service connections. Still no static credentials.

“It’s too much work to migrate.” More work than responding to a credential leak? More work than explaining to your CISO why production secrets are in Git history? The migration is measured in weeks. The breach response is measured in months.

I’ve heard every excuse. None of them hold up against the fundamental reality: static credentials are a liability. Every credential you embed is a credential that can be extracted. Every secret you store is a secret that can leak.

Conclusion

The patterns shown here work in production systems today, handling millions of requests without a single credential in code or configuration. That’s not compliance theater. That’s fundamental security architecture that makes credential leakage technically impossible for Azure resource authentication.

Credentials are hazardous material. Treat them accordingly: contained, time-limited, and scoped to minimum necessary permissions. Or better yet, eliminate them entirely with Managed Identity.

Your future self (and your security team) will thank you.

Comments

VG Wort