Secure, Centralized Configuration in .NET Core with Hashicorp Vault

.Net Core May 30, 2025

When you're building even a handful of microservices, managing connection strings, API keys and secrets across multiple environments (dev, staging, uat, production e.t.c) can quickly get out of hand. Hard-coding values, checking JSON files into source control or juggling a dozen environment variables all invite mistakes and possible security breaches.

HashiCorp Vault gives you a single, network-accessible secrets store. By combining Vault's KV secrets engine with .NET Core's IConfiguration pipeline via the VaultSharp.Extensions.Configuration Nuget package, you can:

  1. Bring up vault with just a few Docker commands
  2. Load a default configuration path (shared across all environments) and then an environment-specific path.
  3. Keep your JSON files for non-sensitive defaults and let Vault override as needed.
  4. Share "common" secrets (e.g your JWT signing key, internal urls) across every API service while still scoping database credentials to each service or environment.

Bootstrapping Hashicorp Vault with Docker Compose

Setup the configuration in vault/config/vault.hcl:

# vault.hcl

ui = true              # enable the web UI
api_addr = "http://0.0.0.0:8200"
cluster_addr = "http://127.0.0.1:8201"
disable_mlock = true

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = true   # dev mode; use TLS in prod
}

storage "file" {
  path = "/vault/data"
}

audit "file" {
  path   = "/vault/logs/audit.log"
  format = "json"
}

Mount the configuration file inside the Docker Compose file:

# vault-docker-compose.yml
version: '3.7'
services:
  vault:
    image: hashicorp/vault
    restart: unless-stopped
    container_name: vault
    ports:
      - "8200:8200"
    environment:
      - VAULT_ADDR=http://127.0.0.1:8200
    command: server
    volumes:
      - ./vault/config:/vault/config:rw
      - ./vault/policies:/vault/policies
      - ./vault/data:/vault/data:rw
      - ./vault/logs:/vault/logs
    cap_add:
      - IPC_LOCK
  • The HCL file lets us express every listener, storage and audit cleanly
  • Docker compose file remains focused on topology and mounts

Once container is up, you'll need to setup the number of keys the root key needs to be split into and the number of keys needed to unseal your vault. Vault is sealed whenever its first runs or is restarted. As the name suggests, the data in it is sealed and is not accessible until the vault is unsealed. The vault can only be unsealed by the unseal keys which have been generated from the root key.

Setting up Unseal key

After setting up unseal keys and unsealing our vault, we'd need to enable the KV Secret engine, set the mount path (default is kv but I have set mine to secret) as well as the maximum number of versions of a secret to be tracked for audit purposes.

Enabling the KV Secret engine

Configuring AppRole Authentication and Policies

Instead of static tokens, use Vault's AppRole to authenticate services and enforce least-privilege. On the left side panel, we can create a new policy using the policies menu option.

Create orders-policy

# Allows VaultSharp library to read metadata to pull the values
path "secret/*/metadata/*" {
  capabilities = ["list"]
}

# Allow reading shared defaults
path "secret/data/Default/*" {
  capabilities = ["read"]
}
# Allow reading JWT secret
path "secret/data/Default/Jwt" {
  capabilities = ["read"]
}
# Allow reading Orders service secrets for the current env (replace `${env}`)
path "secret/data/${env}/Orders/*" {
  capabilities = ["read"]
}

Enable AppRole Auth

In the vault terminal, execute vault auth enable approle

Create An AppRole

vault write auth/approle/role/orders-role \
  token_policies="orders-policy" \
  secret_id_ttl=0 \
  secret_id_num_uses=0
  • secret_id_ttl=0 means the secret IDs generated for this never expire.
  • secret_id_num_uses=0 means secret IDs can be used unlimited times
💡
This is only to be used for during dev. For production, secrets and tokens should be made to expire and the app should request new tokens and secrets.

Fetch the Role ID:

vault read -field=role_id auth/approle/role/orders-policy/role-id

Generate a Secret ID:

vault write -f -field=secret_id auth/approle/role/orders-policy/secret-id

Set your environment variables

VAULT_ROLE_ID=<role-id>
VAULT_SECRET_ID=<secret-id>
VAULT_ADDRESS=https://vault.company.com

Install the Vault Configuration Provider

In each .NET Core project:

dotnet add package VaultSharp.Extensions.Configuration

Organize KV paths for Defaults, Environments & Shared Secrets

Suppose you have three APIs - Orders, Customers and Billing. Each of these services are running in Dev, Staging and Production environments. We need to store secrets under:

secret/data/Default             ← Shared by all services
secret/data/Default/Jwt         ← Common JWT-signing key

secret/data/Dev/Orders          ← Dev-only, Orders-only
secret/data/Dev/Customers
secret/data/Dev/Billing


secret/data/Dev/Common          ← Dev-only, applies to all services in Dev
secret/data/Dev/Common/Jwt      ← Common JWT-signing key across dev

secret/data/Prod/Orders         ← Prod credentials, etc.
… and so on …

The above tree makes it clear what is global vs what is per-service/per-environment.

Setup keys in vault in paths Default, Dev/Orders, Dev/Customers, Dev/Commo e.t.c

Setting secrets

Consuming the Secrets

void ConfigureSecretVault(IConfigurationBuilder configurationBuilder, string environmentName, string serviceName)
{
    var basePath = AppContext.BaseDirectory;
    configurationBuilder.SetBasePath(basePath);

    // 1) Load JSON defaults
    configurationBuilder
        .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
        .AddJsonFile($"appsettings.{environmentName}.json", optional: true);

    // Vault connection details from environment
    var vaultRoleId   = Environment.GetEnvironmentVariable("VAULT_ROLE_ID");
    var vaultSecretId = Environment.GetEnvironmentVariable("VAULT_SECRET_ID");
    var vaultAddress  = Environment.GetEnvironmentVariable("VAULT_ADDRESS");
    var logger        = LoggerFactory.Create(b => b.AddConsole()).CreateLogger("VaultConfig");

    void AddVaultPath(IConfigurationBuilder configBuilder, string path)
    {
        if (string.IsNullOrEmpty(vaultRoleId) ||
            string.IsNullOrEmpty(vaultSecretId) ||
            string.IsNullOrEmpty(vaultAddress))
        {
            logger.LogCritical("Vault variables missing; skipping path {Path}", path);
            return;
        }

        configBuilder.AddVaultConfiguration(
            () => new VaultOptions(vaultAddress, null, vaultSecretId, vaultRoleId, true),
            path,        // e.g. "Default" or "/Dev/Orders", "Dev/Common"
            mountPoint: "secret", //The mount configured while enabling KV engine
            logger: logger
        );
    }


    // 1) Shared defaults
    AddVaultPath(configurationBuilder, "Default");

    // 2) Env Common defaults
    AddVaultPath(configurationBuilder, $"{environmentName}/Common");

    // 3) Environment + Service overrides
    AddVaultPath(configurationBuilder, $"{environmentName}/{serviceName}");
}

// In Program.cs / CreateHostBuilder:
var builder = WebApplication.CreateBuilder(args);
ConfigureSecretVault(builder.Configuration, builder.Environment.EnvironmentName);
// ... register services, etc.

Consuming Secrets in Code

public class OrdersController : ControllerBase
{
    public OrdersController(IConfiguration config)
    {
        var jwtKey   = config["Jwt:Secret"];
        var apiKey   = config["ApiKey"];
        // ...
    }
}

Beyond the Basics

  • Auth Methods: Use Kubernetes Auth or Vault Agent for seamless credential injection
  • Cache & Refresh: Tune TTLs to balance performance with up-to-date secrets
  • Audit & Versioning: leverage KV v2's version history and Vault's audit logs for compliance.

Utilizing AppRole and policies to lock down each service's secrets and layering JSON with vault's KV engine, you achieve zero-trust, centrally managed configuration across your microservices

Tags

Views: Loading...