
Runtime Trust: Injecting a Private CA into .NET at Startup with HashiCorp Vault
Building Dynamic Trust Chains in C# Using Vault PKI
Modern applications rarely live in isolation. Databases, APIs, message queues, and internal services all communicate over TLS—and in many organizations, those certificates are issued by a private Certificate Authority rather than a public one.
This creates a subtle but critical challenge: how does a .NET application learn to trust an internal CA that the operating system knows nothing about?
In this post, we’ll walk through a real-world approach using HashiCorp Vault PKI and .NET—how we designed our application to load and trust a private CA (root or intermediate issuing CA) at runtime without modifying the host environment.
A Note on Prerequisites
This article focuses specifically on runtime trust injection inside a .NET application. It assumes that the surrounding infrastructure is already in place and correctly configured. In particular, we assume the following:
- HashiCorp Vault is deployed and reachable over HTTPS
- The Vault PKI engine is enabled and configured (for example, a
pkiorpki_intmount) - PostgreSQL is configured to require TLS connections
- The PostgreSQL server certificate is issued by the same Vault PKI mount
- Your application already has a working method of authenticating to Vault (such as AppRole, Kubernetes auth, etc.)
- Network connectivity between the application, Vault, and PostgreSQL is fully operational
Setting up Vault PKI, issuing database certificates, or configuring PostgreSQL for SSL are outside the scope of this post. The techniques shown here assume those components are already functional and focus solely on how a .NET application can dynamically trust that internal PKI at runtime.
Full code can be found here:
https://github.com/JBelthoff/dotnet-vault-pki-trust-injection
In This Article
- Building Dynamic Trust Chains in C# Using Vault PKI
- The Problem: Trust That Doesn’t Exist Yet
- Design Goals
- Fetching the Root CA from Vault
- A Thread-Safe CA Provider
- Converting PEM into a Usable Certificate
- Integrating Trust with the Database Layer
- Startup Initialization: Making Trust Deterministic
- Dependency Injection Wiring (Program.cs)
- Appsettings
- Powershell
- Benefits of Runtime CA Injection
- Lessons Learned
- Final Thoughts
The Problem: Trust That Doesn’t Exist Yet
Out of the box, .NET applications rely on the operating system’s trust store. If you connect to a database or service using a certificate signed by a public CA, everything just works.
But internal PKI is different.
In our architecture, PostgreSQL is configured to require TLS connections using certificates issued by Vault’s PKI engine. That means the database presents a server certificate signed by a Vault-managed issuing CA (typically an intermediate chained to a private root).
From .NET’s perspective, this certificate is untrusted.
One option would be to manually install the CA certificate on every host that runs the application. But that approach is brittle, operationally heavy, and incompatible with modern containerized environments.
We wanted something better:
- Zero host-level changes
- No manual certificate distribution
- Automatic trust configuration at application startup
- Secure retrieval of trust material from Vault
- Consistent behavior across Windows and Linux
The solution was to teach the application to trust the Vault-managed issuing CA at runtime.
Design Goals
Before diving into code, we established a few core design principles:
- The application must retrieve the issuing CA certificate directly from Vault.
- Trust configuration must happen before any database connections are established.
- The CA should be cached in memory to avoid repeated network calls.
- Certificate lifecycle must be handled carefully to avoid disposal bugs.
- Startup should fail fast if Vault is unavailable or misconfigured.
These goals shaped the architecture you’ll see below.
Fetching the Root CA from Vault
The first step was to retrieve the Vault PKI issuing CA certificate for the mount we use (root or intermediate).
Vault exposes its CA certificate through the PKI engine. We created a small client abstraction responsible solely for interacting with that endpoint.
Rather than embedding certificate files in Docker images or environment variables, the application queries Vault at runtime and retrieves the canonical source of trust.
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
public interface IVaultPkiClient
{
Task<string> GetRootCaPemAsync(CancellationToken ct = default);
}
public sealed class VaultPkiClient : IVaultPkiClient
{
private readonly HttpClient _http;
private readonly string _pkiMount; // e.g. "pki" or "pki_int"
private readonly Func<CancellationToken, Task<string>> _getTokenAsync;
public VaultPkiClient(
HttpClient http,
string pkiMount,
Func<CancellationToken, Task<string>> getTokenAsync)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
_pkiMount = string.IsNullOrWhiteSpace(pkiMount)
? throw new ArgumentException("PKI mount is required", nameof(pkiMount))
: pkiMount.Trim('/');
_getTokenAsync = getTokenAsync ?? throw new ArgumentNullException(nameof(getTokenAsync));
}
public async Task<string> GetRootCaPemAsync(CancellationToken ct = default)
{
var token = await _getTokenAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(token))
throw new InvalidOperationException("Vault token is missing or empty.");
// Vault PKI CA PEM endpoint:
// GET /v1/<mount>/ca/pem
using var req = new HttpRequestMessage(
HttpMethod.Get,
$"/v1/{_pkiMount}/ca/pem"
);
req.Headers.Add("X-Vault-Token", token);
// Not strictly required, but helps some proxies/content negotiation paths
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-pem-file"));
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain"));
using var resp = await _http.SendAsync(
req,
HttpCompletionOption.ResponseHeadersRead,
ct
).ConfigureAwait(false);
var body = (await resp.Content.ReadAsStringAsync(ct)
.ConfigureAwait(false))
.Trim();
if (!resp.IsSuccessStatusCode)
{
// Vault errors are often JSON; include the body (truncated) for diagnostics
throw new HttpRequestException(
$"Vault PKI CA fetch failed ({(int)resp.StatusCode} {resp.ReasonPhrase}). " +
$"Body: {Truncate(body, 2000)}"
);
}
// This endpoint should return raw PEM, not JSON
if (!body.Contains("-----BEGIN CERTIFICATE-----", StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Vault PKI CA response did not look like PEM. Body: {Truncate(body, 500)}"
);
}
return body;
}
private static string Truncate(string s, int max) =>
string.IsNullOrEmpty(s) || s.Length <= max
? s
: s[..max] + "…";
}
Code language: C# (cs)
Note on HttpClient configuration:
This implementation assumes the injected HttpClient has its BaseAddress set to the Vault server URL (for example https://vault.example.xyz/).
The request URI is constructed as a relative path (/v1/{mount}/ca/pem), so without a configured BaseAddress the client would be unable to resolve the endpoint.
In our project, this client is registered through IHttpClientFactory with the Vault address configured centrally, keeping the PKI client focused, simple, and easily testable.
With that wiring in place, the client provides a clean, narrow contract: “give me the current issuing CA as a PEM string.” If your database certificates come from pki_int, point pkiMount at pki_int so you fetch the intermediate CA that actually issued the server certificates.
Everything else in the system builds on that foundation.
A Thread-Safe CA Provider
Fetching a certificate once is easy. Doing it safely in a multithreaded, long-running application is harder.
We needed a component that would:
- Fetch the CA only once
- Cache it for the lifetime of the process
- Provide safe concurrent access
- Avoid certificate disposal issues
To achieve this, we implemented a dedicated provider abstraction responsible for retrieving and caching the CA certificate used to validate our internal TLS chain.
This class performs a lazy, thread-safe fetch using an async lock. The first request triggers a call to Vault; subsequent requests return the cached result.
using System.Threading;
using System.Threading.Tasks;
public interface IVaultRootCaProvider
{
/// <summary>
/// Retrieves the current Vault PKI Root CA certificate in PEM format.
/// </summary>
Task<string> GetRootCaPemAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Forces a refresh of the cached Root CA PEM from Vault.
/// </summary>
Task RefreshAsync(CancellationToken cancellationToken = default);
}
Code language: C# (cs)
using System;
using System.Threading;
using System.Threading.Tasks;
public sealed class VaultRootCaProvider : IVaultRootCaProvider
{
private readonly IVaultPkiClient _pkiClient;
private readonly TimeSpan _cacheLifetime;
private readonly SemaphoreSlim _sync = new(1, 1);
private string? _cachedPem;
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
public VaultRootCaProvider(
IVaultPkiClient pkiClient,
TimeSpan? cacheLifetime = null)
{
_pkiClient = pkiClient ?? throw new ArgumentNullException(nameof(pkiClient));
// Default to 12 hours if not specified
_cacheLifetime = cacheLifetime ?? TimeSpan.FromHours(12);
}
public async Task<string> GetRootCaPemAsync(CancellationToken cancellationToken = default)
{
// Fast path: return cached value if still valid
if (_cachedPem != null && DateTimeOffset.UtcNow < _expiresAt)
{
return _cachedPem;
}
await _sync.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Re-check inside the lock in case another thread already refreshed
if (_cachedPem != null && DateTimeOffset.UtcNow < _expiresAt)
{
return _cachedPem;
}
var pem = await _pkiClient
.GetRootCaPemAsync(cancellationToken)
.ConfigureAwait(false);
_cachedPem = pem;
_expiresAt = DateTimeOffset.UtcNow.Add(_cacheLifetime);
return pem;
}
finally
{
_sync.Release();
}
}
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _sync.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var pem = await _pkiClient
.GetRootCaPemAsync(cancellationToken)
.ConfigureAwait(false);
_cachedPem = pem;
_expiresAt = DateTimeOffset.UtcNow.Add(_cacheLifetime);
}
finally
{
_sync.Release();
}
}
}
Code language: C# (cs)
A critical design decision here was to cache only the PEM string rather than an X509Certificate2 instance. This avoids subtle ownership and disposal problems that can occur when multiple consumers share the same certificate object.
Converting PEM into a Usable Certificate
While caching PEM is safe and simple, many .NET APIs require an actual X509Certificate2 object.
We therefore use a small helper to convert the cached PEM into a fresh X509Certificate2 instance whenever needed.
This ensures that each consumer receives its own certificate object and can safely dispose it without affecting others.
using System;
using System.Security.Cryptography.X509Certificates;
public static class CertificateConversion
{
/// <summary>
/// Converts a PEM-encoded certificate string into a new X509Certificate2 instance.
/// </summary>
public static X509Certificate2 FromPem(string pem)
{
if (string.IsNullOrWhiteSpace(pem))
throw new ArgumentException("PEM certificate data is required.", nameof(pem));
try
{
return X509Certificate2.CreateFromPem(pem);
}
catch (Exception ex)
{
throw new InvalidOperationException(
"Failed to convert PEM certificate into X509Certificate2. " +
"Ensure the input contains a valid certificate block.",
ex
);
}
}
}
Code language: C# (cs)
This detail turned out to be crucial. Libraries such as Npgsql may internally dispose certificate objects passed into their TLS configuration. By always returning a new instance, we prevent one component from inadvertently invalidating trust for another.
Integrating Trust with the Database Layer
With a reliable source of trust established, the next step was to wire it into our PostgreSQL connectivity.
Npgsql supports strict TLS validation with SslMode=VerifyFull and can be supplied with additional trusted CA certificates programmatically, but we wanted something more dynamic and application-driven.
In our architecture, we build an NpgsqlDataSource with strict TLS validation enabled (SslMode=VerifyFull) and inject Vault’s issuing CA at runtime using a callback.
Instead of relying on a CA certificate file on disk, the application retrieves the CA PEM from the provider and supplies it directly to Npgsql’s TLS trust chain builder.
Whenever it builds a new data source, it requests the issuing CA certificate from the provider and supplies it to Npgsql’s TLS configuration.
using System;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
public static class DatabaseDataSourceFactory
{
public static async Task<NpgsqlDataSource> CreateAsync(
string baseConnectionString,
IVaultRootCaProvider rootCaProvider,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(baseConnectionString))
throw new ArgumentException("Connection string is required.", nameof(baseConnectionString));
if (rootCaProvider is null)
throw new ArgumentNullException(nameof(rootCaProvider));
// Fetch cached PEM once (Vault-backed).
// Keep PEM (not X509Certificate2) as the cached primitive.
var rootPem = await rootCaProvider
.GetRootCaPemAsync(ct)
.ConfigureAwait(false);
// Ensure TLS validation is enabled.
// VerifyFull validates both CA and host name.
var csb = new NpgsqlConnectionStringBuilder(baseConnectionString)
{
SslMode = SslMode.VerifyFull
};
var builder = new NpgsqlDataSourceBuilder(csb.ConnectionString);
// IMPORTANT: return a NEW X509Certificate2 instance each time.
// Some stacks may dispose certificates they consume; reusing the same instance is fragile.
builder.UseRootCertificatesCallback(() =>
{
var col = new X509Certificate2Collection();
col.Add(CertificateConversion.FromPem(rootPem));
// Fresh instance per callback
return col;
});
return builder.Build();
}
}
Code language: C# (cs)
This approach allows the database client to fully validate the PostgreSQL server certificate—even though the operating system itself has no knowledge of the private CA.
Startup Initialization: Making Trust Deterministic
One remaining challenge was ordering.
If the application tried to connect to PostgreSQL before the CA was loaded, connections would fail. We needed a deterministic startup sequence that guaranteed trust was established before any real work began.
To solve this, we implemented a dedicated startup hosted service responsible for Vault initialization.
On application boot, this component performs several critical steps:
- Verifies Vault health
- Validates AppRole authentication
- Retrieves and caches the issuing CA certificate
- Initializes Vault-backed dependencies (for example, database credentials, CA trust material, and other secrets)
- Loads other required secrets
Each step runs under a simple exponential backoff retry to smooth over transient startup failures.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public interface IVaultHealthClient
{
Task CheckHealthyAsync(CancellationToken ct = default);
}
public interface IVaultTokenService
{
/// <summary>
/// Ensures the application can authenticate to Vault (e.g., AppRole)
/// and returns or validates a token.
/// </summary>
Task EnsureAuthenticatedAsync(CancellationToken ct = default);
}
public sealed class VaultStartupHostedService : IHostedService
{
private readonly IVaultHealthClient _health;
private readonly IVaultTokenService _token;
private readonly IVaultRootCaProvider _rootCaProvider;
private readonly ILogger<VaultStartupHostedService> _logger;
private readonly Func<CancellationToken, Task>? _warmDatabaseAsync;
public VaultStartupHostedService(
IVaultHealthClient health,
IVaultTokenService token,
IVaultRootCaProvider rootCaProvider,
ILogger<VaultStartupHostedService> logger,
Func<CancellationToken, Task>? warmDatabaseAsync = null)
{
_health = health ?? throw new ArgumentNullException(nameof(health));
_token = token ?? throw new ArgumentNullException(nameof(token));
_rootCaProvider = rootCaProvider ?? throw new ArgumentNullException(nameof(rootCaProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_warmDatabaseAsync = warmDatabaseAsync;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await RetryAsync(
operationName: "Vault startup initialization",
maxAttempts: 6,
initialDelay: TimeSpan.FromSeconds(1),
maxDelay: TimeSpan.FromSeconds(20),
ct: cancellationToken,
action: InitializeOnceAsync
).ConfigureAwait(false);
}
private async Task InitializeOnceAsync(CancellationToken ct)
{
_logger.LogInformation("Vault init: checking Vault health...");
await _health.CheckHealthyAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Vault init: validating authentication...");
await _token.EnsureAuthenticatedAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Vault init: warming issuing CA cache...");
var pem = await _rootCaProvider.GetRootCaPemAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"Vault init: issuing CA successfully retrieved. Length={Length} bytes.",
pem.Length
);
if (_warmDatabaseAsync is not null)
{
_logger.LogInformation("Vault init: warming database connectivity...");
await _warmDatabaseAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Vault init: database warm-up complete.");
}
}
public Task StopAsync(CancellationToken cancellationToken) =>
Task.CompletedTask;
private async Task RetryAsync(
string operationName,
int maxAttempts,
TimeSpan initialDelay,
TimeSpan maxDelay,
CancellationToken ct,
Func<CancellationToken, Task> action)
{
var attempt = 0;
var delay = initialDelay;
while (true)
{
ct.ThrowIfCancellationRequested();
attempt++;
try
{
await action(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (attempt < maxAttempts)
{
_logger.LogWarning(
ex,
"{Operation} failed on attempt {Attempt}/{MaxAttempts}. Retrying in {Delay}...",
operationName,
attempt,
maxAttempts,
delay
);
await Task.Delay(delay, ct).ConfigureAwait(false);
delay = TimeSpan.FromMilliseconds(
Math.Min(delay.TotalMilliseconds * 2, maxDelay.TotalMilliseconds)
);
}
}
}
}Code language: C# (cs)
By warming the CA PEM cache during startup (and optionally warming a database connection), we ensure the trust material is available before any downstream services are contacted. From there, each database data source injects the issuing CA into Npgsql’s TLS validation callback, keeping trust configuration explicit, deterministic, and fully in-process.
Dependency Injection Wiring (Program.cs)
using System;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Npgsql;
// Program.cs
var builder = Host.CreateApplicationBuilder(args);
var services = builder.Services;
var config = builder.Configuration;
// --- Configuration (examples: appsettings.json / env vars) ---
var vaultAddress = config["Vault:Address"]
?? throw new InvalidOperationException("Vault:Address is required.");
var pkiMount = config["Vault:PkiMount"] ?? "pki";
var caCacheHours = int.TryParse(config["Vault:RootCaCacheHours"], out var h)
? h
: 12;
// Your base DB connection string should be host/db/ssl params.
// (The factory enforces VerifyFull; dynamic creds can be layered elsewhere.)
var baseDbConnString = config.GetConnectionString("Postgres")
?? throw new InvalidOperationException("ConnectionStrings:Postgres is required.");
// --- Vault Health + Token Services ---
services.AddSingleton<IVaultHealthClient, VaultHealthClient>();
services.AddSingleton<IVaultTokenService, VaultTokenService>();
// Provide token getter for VaultPkiClient
services.AddSingleton<Func<CancellationToken, Task<string>>>(sp =>
{
var tokenSvc = sp.GetRequiredService<IVaultTokenService>();
return async (CancellationToken ct) =>
{
await tokenSvc.EnsureAuthenticatedAsync(ct).ConfigureAwait(false);
if (tokenSvc is IHasVaultToken t && !string.IsNullOrWhiteSpace(t.Token))
return t.Token;
throw new InvalidOperationException("Vault token service did not provide a token.");
};
});
// --- HttpClientFactory: Named client for Vault ---
services.AddHttpClient("vault", client =>
{
client.BaseAddress = new Uri(vaultAddress);
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/x-pem-file"));
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("text/plain"));
});
// --- Vault PKI client ---
services.AddSingleton<IVaultPkiClient>(sp =>
{
var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient("vault");
var tokenGetter = sp.GetRequiredService<Func<CancellationToken, Task<string>>>();
return new VaultPkiClient(http, pkiMount, tokenGetter);
});
// --- Root CA provider (cached PEM) ---
services.AddSingleton<IVaultRootCaProvider>(sp =>
{
var pki = sp.GetRequiredService<IVaultPkiClient>();
return new VaultRootCaProvider(pki, TimeSpan.FromHours(caCacheHours));
});
// --- NpgsqlDataSource wiring (issuing CA injected via callback) ---
services.AddSingleton(async sp =>
{
var rootCaProvider = sp.GetRequiredService<IVaultRootCaProvider>();
return await DatabaseDataSourceFactory
.CreateAsync(baseDbConnString, rootCaProvider)
.ConfigureAwait(false);
});
// Expose NpgsqlDataSource synchronously
services.AddSingleton(sp =>
{
var task = sp.GetRequiredService<Task<NpgsqlDataSource>>();
return task.GetAwaiter().GetResult();
});
// --- Optional warm-up: open one TLS connection at startup ---
services.AddSingleton<Func<CancellationToken, Task>>(sp => async (CancellationToken ct) =>
{
var ds = sp.GetRequiredService<NpgsqlDataSource>();
await using var conn = await ds.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand("SELECT 1;", conn);
_ = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
});
// --- Startup hosted service to make ordering deterministic ---
services.AddHostedService(sp => new VaultStartupHostedService(
sp.GetRequiredService<IVaultHealthClient>(),
sp.GetRequiredService<IVaultTokenService>(),
sp.GetRequiredService<IVaultRootCaProvider>(),
sp.GetRequiredService<ILogger<VaultStartupHostedService>>(),
sp.GetRequiredService<Func<CancellationToken, Task>>()
));
// Build + run
var app = builder.Build();
// ----- BEGIN explicit verification block -----
try
{
var dataSource = app.Services.GetRequiredService<NpgsqlDataSource>();
await using var conn = await dataSource.OpenConnectionAsync();
await using var cmd = new NpgsqlCommand("SELECT 1;", conn);
var result = await cmd.ExecuteScalarAsync();
if (result?.ToString() == "1")
{
Console.WriteLine("--------------------------------------------------------");
Console.WriteLine("--------------------------------------------------------");
Console.WriteLine("--------------------------------------------------------");
Console.WriteLine("");
Console.WriteLine("SUCCESS: Vault + PostgreSQL TLS trust fully operational.");
Console.WriteLine("");
Console.WriteLine("--------------------------------------------------------");
Console.WriteLine("--------------------------------------------------------");
Console.WriteLine("--------------------------------------------------------");
}
else
{
Console.WriteLine("---------------------------------------------------------------");
Console.WriteLine("---------------------------------------------------------------");
Console.WriteLine("---------------------------------------------------------------");
Console.WriteLine("FAILURE: Database connectivity test returned unexpected result.");
Console.WriteLine("---------------------------------------------------------------");
Console.WriteLine("---------------------------------------------------------------");
Console.WriteLine("---------------------------------------------------------------");
Environment.Exit(1);
}
}
catch (Exception ex)
{
Console.WriteLine("-----------------------------------------------------------------------");
Console.WriteLine("-----------------------------------------------------------------------");
Console.WriteLine("-----------------------------------------------------------------------");
Console.WriteLine("FAILURE: Unable to establish Vault-backed TLS connection to PostgreSQL.");
Console.WriteLine("Error: " + ex.Message);
Console.WriteLine("-----------------------------------------------------------------------");
Console.WriteLine("-----------------------------------------------------------------------");
Console.WriteLine("-----------------------------------------------------------------------");
Environment.Exit(1);
}
// ----- END explicit verification block -----
await app.RunAsync();
// --------------------
// Article-only helpers
// --------------------
public interface IHasVaultToken
{
string Token { get; }
}
public sealed class VaultHealthClient : IVaultHealthClient
{
public Task CheckHealthyAsync(CancellationToken ct = default) =>
Task.CompletedTask;
}
public sealed class VaultTokenService : IVaultTokenService, IHasVaultToken
{
public string Token { get; private set; } = "example-token";
public Task EnsureAuthenticatedAsync(CancellationToken ct = default) =>
Task.CompletedTask;
}Code language: C# (cs)
Appsettings
{
"Vault": {
"Address": "https://vault.example.com:8200",
"PkiMount": "pki",
"RootCaCacheHours": 12
},
"ConnectionStrings": {
"Postgres": "Host=postgresql.example.com;Port=5432;Database=vaultpkitest;Username=vaultpkitestuser;Password=vaultpkitest;"
}
}
Code language: JSON / JSON with Comments (json)
Powershell
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables --version 10.0.2;
dotnet add package Microsoft.Extensions.Configuration.Json --version 10.0.2;
dotnet add package Microsoft.Extensions.Hosting --version 10.0.2;
dotnet add package Microsoft.Extensions.Http --version 10.0.2;
dotnet add package Npgsql --version 10.0.1Code language: PowerShell (powershell)
Benefits of Runtime CA Injection
This pattern delivered several important advantages:
- No host configuration required – containers and servers remain unchanged
- Operationally manageable rotation – the CA can be refreshed (or the data source rebuilt) without host changes
- Secure distribution – certificates are fetched over authenticated Vault channels
- Consistent behavior – works identically on Windows, Linux, and Docker
- Operational simplicity – no file mounts or manual provisioning
Because trust material is loaded in-process, the application behaves identically whether it runs on a developer laptop, a Linux VM, or a minimal Docker container with no system trust store modifications.
Most importantly, it allows the application to participate fully in an internal PKI ecosystem without leaking environment-specific concerns into the deployment pipeline.
Lessons Learned
A few practical takeaways from implementing this pattern:
- Always return fresh
X509Certificate2instances to avoid disposal bugs - Cache PEM rather than certificate objects
- Perform trust initialization before any outbound TLS connections
- Build explicit startup validation for Vault dependencies
- Treat CA retrieval as a critical path operation
- Validate that you’re fetching the correct issuing CA for the mount (
pkivspki_int) and thatVerifyFullhostnames match your certificate SANs
Final Thoughts
Private PKI doesn’t have to be an operational burden for application developers. With a thoughtful runtime integration, .NET applications can dynamically build trust chains just as easily as they consume secrets and credentials.
By leveraging Vault as the single source of truth and teaching the application to trust it at startup, we achieved a clean, portable, and secure architecture that works seamlessly across environments.
If you’re operating in a Vault-backed ecosystem with internal TLS, consider letting your application manage trust the same way it manages everything else—securely, dynamically, and at runtime.
More coding articles