Skip to content

Read secrets from any vault in .NET — certs, API keys, signing keys

How do I load a TLS client certificate in my .NET app?

Section titled “How do I load a TLS client certificate in my .NET app?”

Applications that talk mTLS, sign JWTs with asymmetric keys, or call third-party APIs need to fetch secrets at runtime — separately from their encryption keys, separately from their DB credentials. ISecretStore is the provider-agnostic API for that:

public sealed class MqttBridge(ISecretStore secrets)
{
public async Task<X509Certificate2> LoadClientCertAsync(CancellationToken ct)
{
SecretDescriptor descriptor = await secrets.GetSecretAsync(
SecretRequest.Latest("mqtt/client-cert"), ct);
// ExpiresOn comes from Azure Key Vault / HashiCorp metadata when available —
// use it to schedule a proactive reload before the certificate expires.
return X509CertificateLoader.LoadPkcs12(descriptor.AsBytes(), password: null);
}
}

One call, one descriptor, one binary blob. Same code runs against HashiCorp, Azure, AWS, or GCP — the provider module you registered decides where it comes from.

Interpretation is provider-specific, but the API stays identical:

ProviderSecretRequest.Name is…Example
HashiCorp KV v2Path relative to Vault:KvMountPoint"mqtt/client-cert"
Azure Key VaultSecret name"mqtt-client-cert"
AWS Secrets ManagerName or full ARN"granit/mqtt/client-cert"
GCP Secret ManagerSecret id, or fully-qualified resource"mqtt-client-cert" or "projects/p/secrets/mqtt-client-cert/versions/latest"

Pin to a specific version when you need to:

// Latest version (default)
await secrets.GetSecretAsync(SecretRequest.Latest("api/third-party-key"), ct);
// Specific version — integer for HashiCorp KV v2, UUID for Azure, GUID for AWS,
// integer or "latest" for GCP. The store validates per-provider and throws
// SecretVaultConfigurationException if the identifier is malformed.
await secrets.GetSecretAsync(SecretRequest.At("api/third-party-key", "7"), ct);

SecretDescriptor.StringValue and SecretDescriptor.BinaryValue are mutually exclusive — IsBinary tells you which. Provider mapping:

  • AWS / GCP — binary is native (SecretBinary MemoryStream / ByteString). No Base64 round-trip. GCP additionally applies a UTF-8 heuristic: valid UTF-8 payloads surface as StringValue so text secrets stay ergonomic.
  • Azure Key Vault — always a string. When ContentType == "application/octet-stream", the Base64-encoded value is decoded into BinaryValue automatically.
  • HashiCorp KV v2 — convention-driven. If the entry contains a __binary field with a Base64 value, the store decodes it into BinaryValue. Otherwise it looks for a value field, or falls back to a JSON serialization of the full entry.

When you don’t care which facet the provider used, AsBytes() and AsString() bridge both:

byte[] pfx = descriptor.AsBytes(); // works for text and binary
string text = descriptor.AsString(); // binary is Base64-encoded for you

What happens when the secret isn’t there?

Section titled “What happens when the secret isn’t there?”

ISecretStore has two read methods with a deliberate contract difference:

// Throws SecretNotFoundException when absent.
Task<SecretDescriptor> GetSecretAsync(SecretRequest request, CancellationToken ct);
// Returns null ONLY when absent. Every other failure bubbles.
Task<SecretDescriptor?> TryGetSecretAsync(SecretRequest request, CancellationToken ct);
ExceptionWhen it surfacesWhat it means for the caller
SecretNotFoundException404 / gRPC NotFound / KV v2 NotFoundThe secret is really absent — create it, or bubble a 404
SecretAccessDeniedException401 / 403 / gRPC PermissionDenied / UnauthenticatedIAM is misconfigured. Fatal; don’t retry.
SecretVaultTransientException429 / 5xx / gRPC Unavailable / DeadlineExceeded / throttlingCandidate for retry in a consumer-chosen Polly pipeline
SecretVaultConfigurationExceptionMissing option, invalid version id, bad resource nameDeveloper error. Fix configuration; don’t try to recover.

Not by default. Caching secrets trades security-by-default for latency; Granit keeps you on the safe side unless you opt in:

{
"Vault": {
"SecretStore": {
"CacheSeconds": 60, // 0 disables (default)
"MaxCachedBinarySizeBytes": 65536 // LOH guard — default 64 KiB
}
}
}

When CacheSeconds > 0, a CachedSecretStore decorator wraps the provider store and reuses the tenant-aware IFusionCache already registered by Granit.Caching. Keys are prefixed vault:secret:{provider}:{name}:{version|latest} — the tenant prefix is applied automatically by TenantAwareFusionCache.

Recommended TTL: ≤ 300 s. There is no push-based invalidation — every provider propagates rotations by version change, not by cache-bust notification. A shorter TTL is a shorter exposure window after rotation.

Binary payloads above MaxCachedBinarySizeBytes skip the cache entirely (and a warning log explains the bypass) to avoid Large Object Heap pressure. Tune the threshold if your PFX certs are larger than 64 KiB.

How do I retry when the vault is temporarily down?

Section titled “How do I retry when the vault is temporarily down?”

The store doesn’t retry automatically — by design. Retry policies are context-specific: startup bootstrap wants aggressive retries, an MQTT hot-path wants failures to bubble in milliseconds, a nightly batch wants exponential back-off. Forcing one policy on every caller would be wrong.

Instead, the store surfaces transient failures via the typed SecretVaultTransientException:

ResiliencePipeline pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<SecretVaultTransientException>(),
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
})
.Build();
SecretDescriptor descriptor = await pipeline.ExecuteAsync(
async ct => await secrets.GetSecretAsync(SecretRequest.Latest("mqtt/cert"), ct),
cancellationToken);

SecretNotFoundException, SecretAccessDeniedException, and SecretVaultConfigurationException are not retry candidates — they won’t succeed by trying again.

Two OpenTelemetry counters and one span per read:

MetricTypeTagsMeaning
granit.vault.secret.readCounterprovider, outcome (ok | not_found | denied | transient | error), cached (true | false), tenant_idTotal reads observed at the decorator boundary
granit.vault.secret.cache_hitCounterprovider, tenant_idReads served from FusionCache without touching the provider
vault.kv.read / akv.get-secret / secrets.obtain / secretmanager.obtainActivitySource spansecret.name, secret.outcomeOne span per provider call (SDK hits only — cache hits don’t emit spans)

Counters are emitted only at the decorator boundary — providers never raise them directly. This guarantees correct accounting even when cache hits bypass the SDK and makes “cache hit ratio” trivial to graph (cache_hit / read).

Wire an opt-in canary read into your readiness probe:

builder.Services.AddHealthChecks()
.AddGranitSecretStoreHealthCheck(name: "vault-secret-store");
{
"Vault": {
"SecretStore": {
"HealthCheckSecretName": "healthcheck/probe"
}
}
}

When HealthCheckSecretName is set, the check reads that secret via ISecretStore and maps the outcome to a health status:

OutcomeStatusWhen
Read succeedsHealthyCanary retrieved
SecretNotFoundExceptionUnhealthyCanary was never provisioned — misconfigured
SecretAccessDeniedExceptionUnhealthyIAM regression
SecretVaultTransientExceptionDegradedTemporary — probe again shortly
SecretVaultException (other)Unhealthy (with exception detail)Config error
Canary not configuredHealthyNo-op — the feature is opt-in