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.
What goes in SecretRequest.Name?
Section titled “What goes in SecretRequest.Name?”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);Is the payload a string or bytes?
Section titled “Is the payload a string or bytes?”SecretDescriptor.StringValue and SecretDescriptor.BinaryValue are mutually
exclusive — IsBinary tells you which. Provider mapping:
- AWS / GCP — binary is native (
SecretBinaryMemoryStream /ByteString). No Base64 round-trip. GCP additionally applies a UTF-8 heuristic: valid UTF-8 payloads surface asStringValueso text secrets stay ergonomic. - Azure Key Vault — always a string. When
ContentType == "application/octet-stream", the Base64-encoded value is decoded intoBinaryValueautomatically. - HashiCorp KV v2 — convention-driven. If the entry contains a
__binaryfield with a Base64 value, the store decodes it intoBinaryValue. Otherwise it looks for avaluefield, 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 binarystring text = descriptor.AsString(); // binary is Base64-encoded for youWhat 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. |
Should I cache secrets?
Section titled “Should I cache secrets?”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.
What telemetry do I get for free?
Section titled “What telemetry do I get for free?”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).
Liveness probe — is my vault reachable?
Section titled “Liveness probe — is my vault reachable?”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 |
See also
Section titled “See also”- Transit encryption —
ITransitEncryptionService, key rotation, re-encryption - Providers — HashiCorp / Azure / AWS / GCP setup
- Database credentials —
IDatabaseCredentialProvider