Secure, Centralized Configuration in .NET Core with Hashicorp Vault
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:
- Bring up vault with just a few Docker commands
- Load a default configuration path (shared across all environments) and then an environment-specific path.
- Keep your JSON files for non-sensitive defaults and let Vault override as needed.
- 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.

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.

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
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

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