Skip to content

Read secrets from any vault in .NET

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:

| Provider | SecretRequest.Name is… | Example | | -------- | ------------------------ | ------- | | HashiCorp KV v2 | Path relative to Vault:KvMountPoint | "mqtt/client-cert" | | Azure Key Vault | Secret name | "mqtt-client-cert" | | AWS Secrets Manager | Name or full ARN | "granit/mqtt/client-cert" | | GCP Secret Manager | Secret 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);

| Exception | When it surfaces | What it means for the caller | | --------- | ---------------- | ---------------------------- | | SecretNotFoundException | 404 / gRPC NotFound / KV v2 NotFound | The secret is really absent — create it, or bubble a 404 | | SecretAccessDeniedException | 401 / 403 / gRPC PermissionDenied / Unauthenticated | IAM is misconfigured. Fatal; don’t retry. | | SecretVaultTransientException | 429 / 5xx / gRPC Unavailable / DeadlineExceeded / throttling | Candidate for retry in a consumer-chosen Polly pipeline | | SecretVaultConfigurationException | Missing option, invalid version id, bad resource name | Developer 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:

| Metric | Type | Tags | Meaning | | ------ | ---- | ---- | ------- | | granit.vault.secret.read | Counter | provider, outcome (ok | not_found | denied | transient | error), cached (true | false), tenant_id | Total reads observed at the decorator boundary | | granit.vault.secret.cache_hit | Counter | provider, tenant_id | Reads served from FusionCache without touching the provider | | vault.kv.read / akv.get-secret / secrets.obtain / secretmanager.obtain | ActivitySource span | secret.name, secret.outcome | One 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:

| Outcome | Status | When | | ------- | ------ | ---- | | Read succeeds | Healthy | Canary retrieved | | SecretNotFoundException | Unhealthy | Canary was never provisioned — misconfigured | | SecretAccessDeniedException | Unhealthy | IAM regression | | SecretVaultTransientException | Degraded | Temporary — probe again shortly | | SecretVaultException (other) | Unhealthy (with exception detail) | Config error | | Canary not configured | Healthy | No-op — the feature is opt-in |