Your appsettings.json Is a Compliance Violation

Your appsettings.json Is a Compliance Violation

Last week I reviewed a client’s .NET application destined for an enterprise Azure deployment. Connection strings sat in plain text in appsettings.Production.json. API keys for payment processors right alongside them. Both committed to GitHub—publicly visible for three months before anyone noticed.

This wasn’t negligence. It was the predictable outcome of teams that never learned why secrets management matters beyond “it’s a best practice.” They’d seen the Azure Key Vault docs. But when deadlines hit, they defaulted to the easiest approach: just put it in the config file.

What they didn’t understand is that secrets in configuration files aren’t just a security anti-pattern. They’re a fundamental violation of ISO/IEC 27017—the international standard governing cloud security controls. And that violation has real consequences: failed audits, denied insurance claims, contractual penalties.

What ISO 27017 Actually Requires

ISO/IEC 27017 extends the foundational ISO 27001 framework with cloud-specific guidance. Control CLD 10.1.2 addresses cryptographic key management directly—and it impacts every .NET application handling production secrets.

The standard doesn’t prescribe specific technologies. It defines control objectives. For secrets management, CLD 10.1.2 requires:

  1. Cryptographic keys protected throughout their lifecycle—generation, distribution, storage, rotation, destruction.
  2. Clear responsibility delineation between cloud provider and customer.
  3. Least-privilege access with explicit authorization policies.

Parent controls A.10.1.1 (Policy on cryptographic controls) and A.10.1.2 (Key management) from ISO 27001 complete the framework: organizations must define cryptographic policies, implement key management procedures, and maintain audit trails for every key access.

I’ve watched teams treat these as abstract compliance checkboxes—something for the security team to worry about, not developers. That’s a fundamental misunderstanding. These controls translate directly into engineering requirements. Every line of code that touches a secret either satisfies them or violates them. There’s no middle ground.

The Shared Responsibility Boundary

Here’s where teams consistently get confused: Azure Key Vault creates a shared responsibility boundary for cryptographic key protection. Understanding this boundary isn’t optional—it’s essential for compliance.

Microsoft’s responsibility (as cloud service provider):

  • Physical security of hardware security modules (HSMs)
  • Encryption of secrets at rest within the vault
  • Network isolation and DDoS protection for the Key Vault service
  • Patch management and security updates
  • SOC 2, ISO 27001, ISO 27017 certification of service infrastructure

Your responsibility (as cloud service customer):

  • Access policies defining who and what can retrieve secrets
  • Network access rules restricting which systems can reach your vault
  • Rotation policies ensuring secrets don’t remain static indefinitely
  • Monitoring and alerting for suspicious access patterns
  • Secure retrieval patterns that don’t expose secrets in logs or memory dumps

When my client’s appsettings.Production.json contained plaintext connection strings, they hadn’t just made a poor technical choice. They had assumed responsibility for all cryptographic protection of those credentials—protection they had no capability to provide. No HSM. No encryption at rest. No access auditing. No rotation procedures. No audit trail.

That’s not a minor oversight. That’s a compliance gap wide enough to drive an audit finding through. And auditors will find it.

The Fatal Pattern: Secrets in Configuration

Let me show you exactly what compliance failure looks like. This pattern appears in far too many production applications I’ve reviewed:

// appsettings.Production.json - The compliance disaster
{
  "ConnectionStrings": {
    "Database": "Server=prod-sql.database.windows.net;Database=OrdersDB;User Id=app_user;Password=SuperSecretP@ssw0rd123;"
  },
  "PaymentGateway": {
    "ApiKey": "pk_live_1234567890",
    "WebhookSecret": "whsec_abcdef123456"
  }
}

Every line of that configuration file represents a compliance violation:

CLD 10.1.2 violation: Cryptographic keys stored without HSM protection, without access controls, without lifecycle management. The storage account key sits there in plain text, accessible to anyone who can read the file.

A.10.1.1 violation: No cryptographic policy governs how these secrets are protected. They’re just… there. Committed to source control. Deployed to servers. Copied between environments.

A.10.1.2 violation: No key management procedure exists. When was that database password last rotated? Who has access to it? What happens when an employee leaves? Nobody knows, because nobody tracked it.

The violations compound when you consider the full deployment pipeline. Secrets echoed to CI/CD logs. Shared in Slack channels (“Hey, I need the production API key for debugging”). Copy-pasted between environments. Stored in plain text on developer laptops.

This cascade of failures doesn’t stem from incompetence or malice. It stems from teams that never understood the control objectives behind secrets management. They know secrets in code are “bad,” but they don’t understand why—so when shortcuts become convenient, they take them.

The Compliant Pattern: Azure Key Vault with Managed Identity

Now let me show you what compliance actually looks like. Not just “better security”—actual ISO 27017 control satisfaction:

var builder = WebApplication.CreateBuilder(args);

// In production, use Managed Identity to authenticate to Key Vault
// In development, DefaultAzureCredential falls back to Azure CLI or Visual Studio
if (!builder.Environment.IsDevelopment())
{
    var keyVaultUri = new Uri($"https://{builder.Configuration["KeyVaultName"]}.vault.azure.net/");
    builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential());
}

The DefaultAzureCredential class deserves attention. It implements a credential chain that automatically selects the appropriate authentication method based on execution environment: Managed Identity for Azure-hosted workloads, Visual Studio credentials for local development, Azure CLI for command-line scenarios. Your code never contains credentials for accessing Key Vault itself—the identity is the authentication. No secrets to manage for accessing the secret store.

// Strongly-typed configuration with validation
public sealed class DatabaseOptions
{
    [Required]
    public string ConnectionString { get; init; } = string.Empty;
}

public sealed class PaymentGatewayOptions
{
    [Required]
    public string ApiKey { get; init; } = string.Empty;

    [Required]
    public string WebhookSecret { get; init; } = string.Empty;
}

// Registration with startup validation - fail fast if secrets are missing
builder.Services.AddOptionsWithValidateOnStart<DatabaseOptions>()
    .BindConfiguration("Database");

builder.Services.AddOptionsWithValidateOnStart<PaymentGatewayOptions>()
    .BindConfiguration("PaymentGateway");

Key Vault secrets map to configuration paths using -- as separator (colons aren’t valid in secret names):

Database--ConnectionString     →  Database:ConnectionString
PaymentGateway--ApiKey         →  PaymentGateway:ApiKey
PaymentGateway--WebhookSecret  →  PaymentGateway:WebhookSecret

This naming convention means your existing IConfiguration-based code doesn’t need to change. The secrets appear in the configuration hierarchy exactly where your code expects them.

Implementing Least-Privilege Access Policies

ISO 27017 control CLD 10.1.2 requires that access to cryptographic keys follow least-privilege principles. Too many teams interpret this as “use Azure AD authentication” and call it done. That’s not least-privilege—that’s just authentication.

Azure Key Vault implements true least-privilege through Azure RBAC:

resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: keyVaultName
  location: location
  properties: {
    tenantId: subscription().tenantId
    sku: { family: 'A', name: 'standard' }
    // Use Azure RBAC instead of legacy access policies
    enableRbacAuthorization: true
    // Compliance requirements: soft-delete and purge protection
    enableSoftDelete: true
    softDeleteRetentionInDays: 90
    enablePurgeProtection: true
    // Network restrictions - deny by default
    networkAcls: {
      defaultAction: 'Deny'
      bypass: 'AzureServices'
    }
  }
}

// Application identity gets ONLY "Secrets User" - read secrets, nothing else
resource appSecretsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(keyVault.id, appIdentity.id, 'Key Vault Secrets User')
  scope: keyVault
  properties: {
    roleDefinitionId: subscriptionResourceId(
      'Microsoft.Authorization/roleDefinitions',
      '4633458b-17de-408a-b874-0445c86b69e6'  // Key Vault Secrets User
    )
    principalId: appIdentity.properties.principalId
    principalType: 'ServicePrincipal'
  }
}

Notice the deliberate role separation:

  • Applications receive Key Vault Secrets User—they can read secrets, nothing else. No listing, no management, no deletion.
  • Operations teams receive Key Vault Secrets Officer—they can manage secrets but not vault configuration.
  • Security administrators receive Key Vault Administrator—full control, assigned sparingly and with justification.

This separation satisfies ISO 27017’s requirement for explicit authorization policies. Every access is attributable to a specific identity. That’s the audit trail compliance demands—and auditors will check.

Soft-Delete and Purge Protection

Two Key Vault features directly address ISO 27017 control A.10.1.2 requirements for key lifecycle management. I’ve seen teams skip these “because we’re careful”—and then watched them panic when an automated script accidentally purged production secrets.

Soft-delete ensures deleted secrets aren’t immediately destroyed. They enter a recoverable state for a configurable retention period (7-90 days). This protects against accidental deletion by operators, malicious deletion by compromised accounts, and recovery needs during incident response.

Purge protection prevents even Key Vault administrators from permanently destroying secrets during the retention period. Once enabled, purge protection cannot be disabled—a deliberate design choice that protects the audit trail. You can’t cover your tracks if you can’t destroy the evidence.

public sealed class KeyVaultHealthCheck : IHealthCheck
{
    private readonly SecretClient _secretClient;
    private readonly ILogger<KeyVaultHealthCheck> _logger;

    public KeyVaultHealthCheck(SecretClient secretClient, ILogger<KeyVaultHealthCheck> logger)
    {
        _secretClient = secretClient;
        _logger = logger;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        try
        {
            // Verify we can reach Key Vault and have appropriate permissions
            await _secretClient.GetPropertiesOfSecretsAsync(ct)
                .AsPages(pageSizeHint: 1).FirstAsync(ct);
            return HealthCheckResult.Healthy("Key Vault connection verified");
        }
        catch (RequestFailedException ex) when (ex.Status == 403)
        {
            _logger.LogError(ex, "Key Vault access denied - check Managed Identity role assignment");
            return HealthCheckResult.Unhealthy("Key Vault access denied - RBAC misconfiguration");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Key Vault health check failed");
            return HealthCheckResult.Unhealthy("Key Vault unavailable", ex);
        }
    }
}

This health check catches configuration errors before they become production incidents. Deploy to a new environment with a misconfigured Managed Identity? The health check fails immediately, before users hit the application.

GitHub Actions Without Secret Exposure

CI/CD pipelines are a frequent source of secret leakage—and ISO 27017 compliance extends to your deployment infrastructure. I’ve reviewed pipelines where developers echo environment variables to logs “for debugging,” permanently exposing production credentials in build history.

name: Deploy to Azure

on:
  push:
    branches: [main]

permissions:
  id-token: write  # Required for OIDC - no long-lived secrets
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Requires approval for production deploys

    steps:
      - uses: actions/checkout@v4

      - name: Azure Login via OIDC
        uses: azure/login@v2
        with:
          # These are NOT secrets - they're identifiers
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Build Application
        run: dotnet publish -c Release -o ./publish

      - name: Deploy to App Service
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ vars.APP_SERVICE_NAME }}
          package: ./publish
      # App Service retrieves secrets from Key Vault via Managed Identity
      # No secrets pass through this pipeline - ever

The key principles here matter:

  1. OIDC authentication eliminates long-lived service principal secrets. The workflow requests a short-lived token for each run.
  2. Secrets never appear in workflow logs—use vars for non-sensitive configuration, never echo values.
  3. Managed Identity handles Key Vault access—the deployment pipeline doesn’t need to know production secrets.
  4. Environment protection rules require approval for production deployments, creating an audit trail.

Automatic Key Rotation

ISO 27017 control A.10.1.2 requires key lifecycle management, including rotation. “We’ll rotate it when we remember” isn’t a rotation policy—it’s a prayer. Key Vault supports automatic rotation for certain secret types:

resource rotationPolicy 'Microsoft.KeyVault/vaults/secrets/rotationPolicies@2023-07-01' = {
  parent: storageKeySecret
  name: 'default'
  properties: {
    rotationPolicy: {
      lifetimeActions: [
        {
          trigger: { timeAfterCreate: 'P60D' }  // 60 days after creation
          action: { type: 'Rotate' }
        }
        {
          trigger: { timeBeforeExpiry: 'P30D' }  // 30 days before expiry
          action: { type: 'Notify' }
        }
      ]
      attributes: { expiryTime: 'P90D' }  // Secret expires after 90 days
    }
  }
}

For secrets that can’t be automatically rotated—third-party API keys, legacy system credentials—implement monitoring to track age. Don’t rely on humans remembering:

public sealed class SecretExpirationMonitor : BackgroundService
{
    private readonly SecretClient _secretClient;
    private readonly ILogger<SecretExpirationMonitor> _logger;
    private readonly TimeSpan _warningThreshold = TimeSpan.FromDays(30);

    public SecretExpirationMonitor(SecretClient secretClient, ILogger<SecretExpirationMonitor> logger)
    {
        _secretClient = secretClient;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var timer = new PeriodicTimer(TimeSpan.FromHours(24));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            await foreach (var secret in _secretClient.GetPropertiesOfSecretsAsync(stoppingToken))
            {
                if (secret.ExpiresOn.HasValue)
                {
                    var timeUntilExpiry = secret.ExpiresOn.Value - DateTimeOffset.UtcNow;

                    if (timeUntilExpiry <= _warningThreshold)
                    {
                        _logger.LogWarning(
                            "Secret {SecretName} expires in {Days} days. Rotation required.",
                            secret.Name,
                            timeUntilExpiry.Days);
                        // Integration point: send alert to ops team, create ticket, etc.
                    }
                }
            }
        }
    }
}

From Configuration Files to Compliance

The path from appsettings.Production.json to ISO 27017 compliance isn’t just about moving secrets to Key Vault. It requires a fundamental shift in how teams think about credential management:

Secrets are not configuration. They require dedicated infrastructure with access controls, rotation policies, and audit trails. Treating them as configuration values—even encrypted configuration values—misses the point entirely. Configuration tells your application how to behave. Secrets tell it who it is. Those are fundamentally different concerns.

Identity replaces secrets wherever possible. Managed Identity for Key Vault access, OIDC for CI/CD authentication, Entra ID for database connections. Each identity-based authentication eliminates a secret that would otherwise require management. The best secret is the one that doesn’t exist.

Access must be explicit and auditable. Every system and person that can retrieve secrets must be explicitly authorized through RBAC. Every access must generate an audit event. When an auditor asks “who accessed this secret in the last 90 days,” you need an answer—not a shrug.

Rotation must be procedural, not heroic. When rotation depends on someone remembering to update a secret, it won’t happen reliably. Automated rotation for supported services. Monitoring and alerting for everything else. The goal is systems that fail safely when secrets expire, not systems that depend on human vigilance.

The teams that get this right aren’t necessarily more security-conscious than others. They’re the ones who understood that ISO 27017 controls exist for practical reasons—they codify lessons from decades of security failures into engineering requirements.

Your appsettings.Production.json file shouldn’t contain secrets. Not because it’s a best practice. Not because some security checklist told you so. Because ISO 27017 controls CLD 10.1.2, A.10.1.1, and A.10.1.2 require cryptographic key protection that configuration files simply cannot provide.

Azure Key Vault, Managed Identity, and proper access controls aren’t optional security enhancements. They’re the minimum viable implementation of international compliance standards that your organization probably already claims to follow.

The shared responsibility model is clear: Microsoft secures the vault. You secure the access. Neither works without the other.

Comments

VG Wort