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 = ["read", "list"]
}
# Allow reading shared defaults
path "secret/data/Default/*" {
capabilities = ["read", "list"]
}
# Allow reading JWT secret
path "secret/data/Default/Jwt" {
capabilities = ["read", "list"]
}
# Allow reading Orders service secrets for the current env (replace `${env}`)
path "secret/data/${env}/Orders/*" {
capabilities = ["read"]
}If you're wondering why we need secret/metadata/ and secret/data/ instead of just secret/*, this is due to Vault's KV v2 arhcitecture. KV v2 treats secrets as version objects and not simple key-value pairs.
/data/paths - Where your actual secret values live. When your application reads configuration, it accesses these paths./metadata/paths - Contains information about your secrets (version history, deletion timestamps e.t.c). Required for listing operations.
This separation enables:
- Granular permissions - Grant read access to values while restricting management operations.
- Version control - KV v2 maintains multiple versions of each secret.
- Listing capabilities - The
metadatapath withlistcapability allows discovering what secrets exist without reading their values.
Without the metadata permission, your service could read specific secrets but would fail when trying to list available secrets - an operation VaultSharp.Configuration.Extensions performs automatically before reading the secrets to discover what configuration keys are available.
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=0secret_id_ttl=0means the secret IDs generated for this never expire.secret_id_num_uses=0means 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.comInstall 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