Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 079 / 100

Managing secrets in .NET: a hands-on guide for engineers

Secrets never belong in appsettings.json.

Expona Admin7 min read

Managing secrets in .NET

I've seen database passwords hardcoded in appsettings.json, API keys committed to Git, and connection strings sitting in plain text across multiple environment configs. Managing secrets in .NET isn't optional—it's the difference between a system you can sleep on and one waiting for a breach. Let me walk you through how I handle this across local development, CI/CD pipelines, and production at scale.

Why secrets in appsettings.json fail

When you store secrets in appsettings.json, you've already lost. Here's why:

  • Git history is forever—even if you delete the file later, the secret lives in every clone and backup.
  • appsettings.json gets checked into version control by default in most project templates.
  • Anyone with repository access (including former team members) can read production secrets.
  • You can't rotate secrets without code changes and redeploys.

I've inherited codebases where the first thing I did was rotate every single secret because I couldn't trust who'd seen what. Don't be that team.

Local development: dotnet user-secrets

For local work, .NET provides User Secrets Manager. It stores secrets outside your project directory—on Windows in %APPDATA%\Microsoft\UserSecrets, on macOS/Linux in ~/.microsoft/usersecrets. The file is unencrypted but isolated from your repo.

Here's the workflow. First, initialize secrets for your project:

csharp
dotnet user-secrets init

This creates a UserSecretsId in your .csproj file. Then set individual secrets:

csharp
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;Database=mydb;User Id=sa;Password=YourActualPassword"
dotnet user-secrets set "ApiKeys:ExternalService" "sk_live_your_real_api_key"
dotnet user-secrets set "JwtSecret" "your-256-bit-secret-key-here"

Your appsettings.json stays clean and committed:

csharp
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=mydb;User Id=sa;Password=****"
  },
  "ApiKeys": {
    "ExternalService": "sk_live_****"
  },
  "JwtSecret": "****"
}

In Startup or Program.cs, the configuration system automatically merges user secrets with your appsettings:

csharp
var builder = WebApplication.CreateBuilder(args);

if (builder.Environment.IsDevelopment())
{
    builder.Configuration.AddUserSecrets();
}

builder.Services.Configure<DatabaseOptions>(builder.Configuration.GetSection("ConnectionStrings"));
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection("ApiKeys"));

The key benefit: you can list secrets with dotnet user-secrets list, remove them individually, or wipe everything for a fresh start. Each developer has their own isolated secrets that never touch Git.

CI/CD pipelines: secret variables

When your pipeline runs tests or builds artifacts, it needs secrets—but not hardcoded. Every CI/CD platform (GitHub Actions, Azure Pipelines, GitLab CI, Jenkins) supports encrypted secret variables that are injected at runtime.

Here's a GitHub Actions example:

csharp
name: Build and Test

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '8.0'
      
      - name: Restore dependencies
        run: dotnet restore
      
      - name: Build
        run: dotnet build --configuration Release --no-restore
      
      - name: Run tests
        run: dotnet test --configuration Release --no-build
        env:
          ConnectionStrings__DefaultConnection: ${{ secrets.DB_CONNECTION_STRING }}
          ApiKeys__ExternalService: ${{ secrets.EXTERNAL_API_KEY }}
          JwtSecret: ${{ secrets.JWT_SECRET }}

Each secret is stored encrypted in your repository settings. GitHub doesn't log them, masks them in output, and only injects them into steps that explicitly request them. When a pull request comes from a fork, secrets aren't available—a security boundary worth respecting.

The pattern is consistent across platforms: store encrypted values, reference them by name in your workflow, and pass them as environment variables to your build commands. Your .NET app reads them through the standard IConfiguration interface—no changes needed.

Production: Key Vault or AWS Secrets Manager

Local and CI/CD are staging grounds. Production secrets live in managed vaults where you control who accesses them, audit every read, and rotate keys without touching code.

Azure Key Vault

If you're on Azure, Key Vault is the native choice. Install the NuGet package:

csharp
dotnet add package Azure.Identity
dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets

Then wire it into your configuration during app startup:

csharp
var builder = WebApplication.CreateBuilder(args);

var keyVaultUrl = new Uri(builder.Configuration["KeyVault:Uri"]);
var credential = new DefaultAzureCredential();

builder.Configuration.AddAzureKeyVault(
    keyVaultUrl,
    credential,
    new KeyVaultSecretManager());

var app = builder.Build();

Your App Service or Container Instance authenticates to Key Vault using Managed Identity—no credentials to store. When your app starts, it pulls secrets on demand. You rotate keys in the vault; the app picks up changes without redeployment (with caching considerations).

AWS Secrets Manager

On AWS, Secrets Manager is the equivalent. Use the .NET SDK:

csharp
dotnet add package AWSSDK.SecretsManager

Load secrets during startup:

csharp
var builder = WebApplication.CreateBuilder(args);

var secretsClient = new AmazonSecretsManagerClient(RegionEndpoint.USEast1);

try
{
    var secretRequest = new GetSecretValueRequest
    {
        SecretId = "prod/myapp/database"
    };
    var secretResponse = await secretsClient.GetSecretValueAsync(secretRequest);
    var secretString = secretResponse.SecretString;
    
    var secrets = JsonSerializer.Deserialize<Dictionary<string, string>>(secretString);
    
    foreach (var kvp in secrets)
    {
        builder.Configuration[kvp.Key] = kvp.Value;
    }
}
catch (Exception ex)
{
    // Log and fail fast if secrets aren't available
    throw;
}

var app = builder.Build();

Your EC2 instance or ECS task role has permissions to read from Secrets Manager. Secrets are encrypted at rest and in transit. You can rotate them through the AWS console without touching your application code.

Rotating keys and auditing access

Storing secrets safely is half the battle. The other half is knowing when and how to rotate them, and who's accessing them.

Rotation strategy

For API keys and tokens, I rotate on a schedule—typically every 90 days for critical credentials. Some services (like database passwords) should rotate more frequently. Here's the process:

  1. Generate a new secret in your vault (Key Vault or Secrets Manager).
  2. Update your application configuration to use the new value.
  3. Test thoroughly in staging.
  4. Deploy to production.
  5. Wait 24-48 hours to ensure the new value is stable.
  6. Revoke the old secret in your external service (e.g., tell your API provider to disable the old key).

Some services support key versioning. AWS Secrets Manager lets you store multiple versions and switch between them seamlessly. Azure Key Vault supports secret versions with timestamps. Use this—it gives you a rollback path if rotation fails.

Auditing

Both Key Vault and Secrets Manager log all access. Enable this logging to a central store—Azure Monitor for Key Vault, CloudTrail for AWS Secrets Manager. Set up alerts when someone accesses production secrets outside normal patterns.

In Key Vault, audit logs show who accessed what and when. If a developer leaves, you can verify no one used their credentials to read secrets after their last day. In AWS, CloudTrail records GetSecretValue calls with the principal's identity and timestamp.

I also restrict access at the IAM level. In production, only your application's service account can read secrets. Developers get read access in staging only. This principle of least privilege means a compromised developer machine can't exfiltrate production secrets.

Common mistakes

  • Committing appsettings.Production.json to Git. Even if you ignore it later, it's in history. Use environment-specific vaults instead, and keep all appsettings files generic templates in version control.
  • Hardcoding Key Vault or Secrets Manager URLs. These should also come from configuration, pulled from a safe place (environment variable, ARM template, Terraform).
  • Not caching secrets locally. Calling Secrets Manager on every request is slow and expensive. Cache for 5-15 minutes in memory. If the vault is unreachable, fall back to the cached value rather than crashing.
  • Logging secrets accidentally. If an exception includes a connection string, it might end up in application logs. Use structured logging and explicitly exclude sensitive fields.
  • Forgetting to rotate. Set calendar reminders or use automated rotation features where available. AWS Secrets Manager can automatically rotate certain services; use this when possible.
  • Over-permissioning vault access. If your whole team can manage production secrets, rotation and audit lose their meaning. Restrict to senior engineers or automated deployment accounts.

Key takeaways

  • Never store secrets in appsettings.json or any version-controlled file. Once in Git history, it's compromised forever.
  • Use dotnet user-secrets for local development, isolating secrets from your repository entirely.
  • Store secrets in CI/CD pipeline variables (GitHub Actions, Azure Pipelines, etc.), injected as environment variables at build time.
  • Use managed vaults in production—Azure Key Vault or AWS Secrets Manager—with Managed Identity for authentication and no hardcoded credentials.
  • Rotate critical secrets on a schedule (90 days for API keys, more frequently for passwords) and always test rotation in staging first.
  • Enable audit logging on your vaults. Know who accessed what and when. Alert on unusual access patterns.
  • Follow least privilege: only the application service account reads production secrets. Developers get staging access only.
  • Cache secrets locally to reduce latency and vault load, but have a fallback strategy if the vault becomes unreachable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles