Multi-tenant SaaS apps can charge AI traffic to each tenant's own provider account — OpenAI, Anthropic,
or Azure OpenAI — instead of routing everything through a shared host key. The built-in
IAiKeyVault abstraction is the seam.
On every AI request, the resolver consults IAiKeyVault.GetKeyAsync with an
AiKeyRequestContext that carries the active HttpContext, provider name, and AI mode.
Your vault implementation extracts the tenant id (typically from a claim) and returns the right
AiKeyMaterial — an ApiKey plus an optional KeyId for audit attribution.
The default OptionsBackedAiKeyVault returns Empty, so resolvers fall back to
AiResolverOptions.ApiKey. Non-BYOK customers see zero behaviour change. When you
register a real vault, every request flows through it.
public interface IAiKeyVault { Task<AiKeyMaterial> GetKeyAsync(AiKeyRequestContext context, CancellationToken ct = default); } public sealed class AiKeyRequestContext { public HttpContext? HttpContext { get; init; } public string Provider { get; init; } // "OpenAI" / "Anthropic" / "AzureOpenAI" public string? Mode { get; init; } // "proofread" / "summarize" / etc. } public sealed class AiKeyMaterial { public string ApiKey { get; init; } // the secret (never logged) public string? KeyId { get; init; } // non-sensitive id for audit // Azure-only per-tenant overrides: public string? AzureEndpoint { get; init; } public string? AzureDeploymentName { get; init; } public string? AzureApiVersion { get; init; } }
The library ships an InMemoryAiKeyVault that implements both IAiKeyVault and
IAiKeyVaultAdmin — useful for prototypes and tests. State vanishes on restart; not for production.
builder.Services.AddRichTextBox(); builder.Services.AddSingleton<InMemoryAiKeyVault>(); builder.Services.AddSingleton<IAiKeyVault>(sp => sp.GetRequiredService<InMemoryAiKeyVault>()); builder.Services.AddSingleton<IAiKeyVaultAdmin>(sp => sp.GetRequiredService<InMemoryAiKeyVault>()); builder.Services.AddRichTextBoxOpenAiResolver(opts => { // No baked-in ApiKey — the vault provides per-tenant keys. opts.AllowEmptyApiKey = true; opts.Model = "gpt-4o-mini"; }); // Admin endpoints behind your auth middleware (off by default). app.MapRichTextBoxUploads(); app.MapRichTextBoxAiKeyVaultAdmin().RequireAuthorization("AdminOnly");
InMemoryAiKeyVault looks for the first claim matching one of
tenant_id, tid, tenant. Override TenantClaimTypes for a different
scheme, or implement your own IAiKeyVault if you need to read the tenant from a header / route /
subdomain.
For multi-instance deployments where the in-memory reference doesn’t fit and Azure Key Vault is overkill,
a Redis-backed implementation gives you persistence + cross-instance consistency in ~50 lines. Drop into
your project after dotnet add package StackExchange.Redis:
using RichTextBox.AiResolvers; using StackExchange.Redis; using System.Security.Claims; using System.Text.Json; public sealed class RedisAiKeyVault : IAiKeyVault, IAiKeyVaultAdmin { private readonly IConnectionMultiplexer _redis; private readonly IHttpContextAccessor _http; private const string KeyPrefix = "rtb:vault:"; private const string TenantClaim = "tenant_id"; public RedisAiKeyVault(IConnectionMultiplexer redis, IHttpContextAccessor http) { _redis = redis; _http = http; } public async Task<AiKeyMaterial> GetKeyAsync(AiKeyRequestContext ctx, CancellationToken ct = default) { var tenant = _http.HttpContext?.User.FindFirstValue(TenantClaim); if (string.IsNullOrEmpty(tenant)) return AiKeyMaterial.Empty; var db = _redis.GetDatabase(); var raw = await db.StringGetAsync(KeyPrefix + tenant + ":" + ctx.Provider); if (raw.IsNullOrEmpty) return AiKeyMaterial.Empty; var wire = JsonSerializer.Deserialize<Wire>(raw!); if (wire is null) return AiKeyMaterial.Empty; return new AiKeyMaterial { ApiKey = wire.ApiKey ?? "", KeyId = wire.KeyId, AzureEndpoint = wire.AzureEndpoint, AzureDeploymentName = wire.AzureDeploymentName, AzureApiVersion = wire.AzureApiVersion, }; } public async Task<AiKeyVaultEntry> UpsertAsync(AiKeyVaultUpsertRequest req, CancellationToken ct = default) { var keyId = string.IsNullOrWhiteSpace(req.KeyId) ? Guid.NewGuid().ToString("N")[..12] : req.KeyId!; var now = DateTimeOffset.UtcNow; var wire = new Wire { KeyId = keyId, TenantId = req.TenantId, Provider = req.Provider, ApiKey = req.ApiKey, CreatedUtc = now, LastRotatedUtc = now, AzureEndpoint = req.AzureEndpoint, AzureDeploymentName = req.AzureDeploymentName, AzureApiVersion = req.AzureApiVersion, }; var db = _redis.GetDatabase(); await db.StringSetAsync(KeyPrefix + req.TenantId + ":" + req.Provider, JsonSerializer.Serialize(wire)); await db.SetAddAsync(KeyPrefix + "index", keyId); // for ListAsync return new AiKeyVaultEntry { KeyId = keyId, TenantId = req.TenantId, Provider = req.Provider, CreatedUtc = now, LastRotatedUtc = now, AzureEndpoint = req.AzureEndpoint, AzureDeploymentName = req.AzureDeploymentName, AzureApiVersion = req.AzureApiVersion, }; } public async Task<IReadOnlyList<AiKeyVaultEntry>> ListAsync(string? tenantId = null, CancellationToken ct = default) { // Implementation: SCAN over KeyPrefix*, deserialise, filter by tenant if supplied. // Omitted for brevity. See the InMemoryAiKeyVault for the entry-projection shape. return Array.Empty<AiKeyVaultEntry>(); } public async Task<bool> DeleteAsync(string keyId, CancellationToken ct = default) { // Implementation: SCAN to find which tenant:provider key holds keyId, then DEL. var db = _redis.GetDatabase(); await db.SetRemoveAsync(KeyPrefix + "index", keyId); return true; } private sealed class Wire { public string? KeyId { get; set; } public string? TenantId { get; set; } public string? Provider { get; set; } public string? ApiKey { get; set; } public DateTimeOffset CreatedUtc { get; set; } public DateTimeOffset? LastRotatedUtc { get; set; } public string? AzureEndpoint { get; set; } public string? AzureDeploymentName { get; set; } public string? AzureApiVersion { get; set; } } }
Wire it up:
builder.Services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"])); builder.Services.AddSingleton<RedisAiKeyVault>(); builder.Services.AddSingleton<IAiKeyVault>(sp => sp.GetRequiredService<RedisAiKeyVault>()); builder.Services.AddSingleton<IAiKeyVaultAdmin>(sp => sp.GetRequiredService<RedisAiKeyVault>()); builder.Services.AddRichTextBox(); builder.Services.AddRichTextBoxOpenAiResolver(opts => opts.AllowEmptyApiKey = true);
IDataProtectionProvider on top — encrypt before StringSetAsync,
decrypt after StringGetAsync. The shipped FileBackedAiKeyVault is a good reference
for the encrypt/decrypt envelope.
For production-grade compliance (HIPAA / PCI / SOC2), persist keys in Azure Key Vault and let the
platform handle KMS-backed encryption, access policies, audit logging, and rotation. Add the SDK with
dotnet add package Azure.Security.KeyVault.Secrets and
Azure.Identity.
using Azure; using Azure.Identity; using Azure.Security.KeyVault.Secrets; using RichTextBox.AiResolvers; using System.Security.Claims; public sealed class AzureKeyVaultAiKeyVault : IAiKeyVault, IAiKeyVaultAdmin { private readonly SecretClient _client; private readonly IHttpContextAccessor _http; private const string TenantClaim = "tenant_id"; public AzureKeyVaultAiKeyVault(SecretClient client, IHttpContextAccessor http) { _client = client; _http = http; } public async Task<AiKeyMaterial> GetKeyAsync(AiKeyRequestContext context, CancellationToken ct = default) { var tenantId = _http.HttpContext?.User.FindFirstValue(TenantClaim); if (string.IsNullOrEmpty(tenantId)) return AiKeyMaterial.Empty; var secretName = SecretName(tenantId, context.Provider); try { var secret = await _client.GetSecretAsync(secretName, cancellationToken: ct); var tags = secret.Value.Properties.Tags; return new AiKeyMaterial { ApiKey = secret.Value.Value, KeyId = secret.Value.Properties.Version, AzureEndpoint = tags.TryGetValue("azureEndpoint", out var ep) ? ep : null, AzureDeploymentName = tags.TryGetValue("azureDeployment", out var dep) ? dep : null, AzureApiVersion = tags.TryGetValue("azureApiVersion", out var ver) ? ver : null, }; } catch (RequestFailedException ex) when (ex.Status == 404) { return AiKeyMaterial.Empty; } } public async Task<AiKeyVaultEntry> UpsertAsync(AiKeyVaultUpsertRequest req, CancellationToken ct = default) { var name = SecretName(req.TenantId, req.Provider); var result = await _client.SetSecretAsync(name, req.ApiKey, ct); var props = result.Value.Properties; // Azure-only routing data goes into tags so we don't have to JSON-encode // it inside the secret value (keeps the secret value pure key text). if (!string.IsNullOrEmpty(req.AzureEndpoint)) props.Tags["azureEndpoint"] = req.AzureEndpoint; if (!string.IsNullOrEmpty(req.AzureDeploymentName)) props.Tags["azureDeployment"] = req.AzureDeploymentName; if (!string.IsNullOrEmpty(req.AzureApiVersion)) props.Tags["azureApiVersion"] = req.AzureApiVersion; props.Tags["tenantId"] = req.TenantId; props.Tags["provider"] = req.Provider; await _client.UpdateSecretPropertiesAsync(props, ct); return new AiKeyVaultEntry { KeyId = props.Version!, TenantId = req.TenantId, Provider = req.Provider, CreatedUtc = props.CreatedOn ?? DateTimeOffset.UtcNow, LastRotatedUtc = props.UpdatedOn, AzureEndpoint = req.AzureEndpoint, AzureDeploymentName = req.AzureDeploymentName, AzureApiVersion = req.AzureApiVersion, }; } public async Task<IReadOnlyList<AiKeyVaultEntry>> ListAsync(string? tenantId = null, CancellationToken ct = default) { var entries = new List<AiKeyVaultEntry>(); await foreach (var p in _client.GetPropertiesOfSecretsAsync(ct)) { if (p.Tags.TryGetValue("tenantId", out var tid) && (tenantId is null || tid == tenantId)) { entries.Add(new AiKeyVaultEntry { KeyId = p.Version!, TenantId = tid, Provider = p.Tags.TryGetValue("provider", out var pr) ? pr : "", CreatedUtc = p.CreatedOn ?? DateTimeOffset.UtcNow, LastRotatedUtc = p.UpdatedOn, AzureEndpoint = p.Tags.TryGetValue("azureEndpoint", out var ep) ? ep : null, AzureDeploymentName = p.Tags.TryGetValue("azureDeployment", out var dep) ? dep : null, AzureApiVersion = p.Tags.TryGetValue("azureApiVersion", out var ver) ? ver : null, }); } } return entries; } public async Task<bool> DeleteAsync(string keyId, CancellationToken ct = default) { // Caller passes the version id; resolve back to the secret name via tags. await foreach (var p in _client.GetPropertiesOfSecretsAsync(ct)) { if (p.Version == keyId) { await _client.StartDeleteSecretAsync(p.Name, ct); return true; } } return false; } private static string SecretName(string tenantId, string provider) => $"rtb-{provider.ToLowerInvariant()}-{tenantId}"; }
Wire it up:
builder.Services.AddSingleton(_ => new SecretClient( vaultUri: new Uri(builder.Configuration["AzureKeyVault:Uri"]!), credential: new DefaultAzureCredential())); builder.Services.AddSingleton<AzureKeyVaultAiKeyVault>(); builder.Services.AddSingleton<IAiKeyVault>(sp => sp.GetRequiredService<AzureKeyVaultAiKeyVault>()); builder.Services.AddSingleton<IAiKeyVaultAdmin>(sp => sp.GetRequiredService<AzureKeyVaultAiKeyVault>()); builder.Services.AddRichTextBox(); builder.Services.AddRichTextBoxOpenAiResolver(opts => opts.AllowEmptyApiKey = true);
IMemoryCache works
fine. Don't cache for hours: a key rotation should propagate to live traffic within a minute or two.
app.MapRichTextBoxAiKeyVaultAdmin() wires three routes when an IAiKeyVaultAdmin is
registered. Wrap them in your own auth. The library does not assume an auth model.
| Route | Body / Query | Returns |
|---|---|---|
POST /richtextbox/ai/vault/keys |
{ tenantId, provider, apiKey, keyId?, azureEndpoint?, azureDeploymentName?, azureApiVersion? } |
200 + non-sensitive entry metadata |
GET /richtextbox/ai/vault/keys?tenant=<id> |
Optional ?tenant= filter |
200 + array of entries (no secrets) |
DELETE /richtextbox/ai/vault/keys/{keyId} |
— | 204 on success, 404 if unknown |
curl -X POST https://app.example.com/richtextbox/ai/vault/keys \ -H "Authorization: Bearer <admin-jwt>" \ -H "Content-Type: application/json" \ -d '{ "tenantId": "acme-corp", "provider": "OpenAI", "apiKey": "sk-acme-...", "keyId": "acme-prod-2026-04" }' # Response (no secret): { "keyId": "acme-prod-2026-04", "tenantId": "acme-corp", "provider": "OpenAI", "createdUtc": "2026-04-25T19:14:02.512Z", "lastRotatedUtc": "2026-04-25T19:14:02.512Z" }
Every key operation emits a structured log entry:
| EventId | Name | Fires when |
|---|---|---|
| 8204 | AiKeyVaultMiss | Vault returned Empty + no fallback ApiKey → client sees friendly "AI not configured" message. |
| 8205 | AiKeyVaultHit | Vault returned a key. KeyId is logged; the secret never is. |
KeyId on hit. Custom vault
implementations should follow the same discipline — treat ApiKey as never-loggable.
BYOK pairs naturally with the per-call cost ledger. Implement IRichTextBoxAiCostSink and you'll
get an AiUsageRecord per AI call — provider, model, mode, input/output/total tokens, latency,
and the KeyId from the vault hit. Forward to your billing system for chargeback:
public sealed class BillingAiCostSink : IRichTextBoxAiCostSink { private readonly ITenantBillingClient _billing; public BillingAiCostSink(ITenantBillingClient billing) => _billing = billing; public Task RecordAsync(AiUsageRecord record, CancellationToken ct = default) { // KeyId came from your vault hit — use it to look up which tenant. return _billing.EnqueueAsync(new { keyId = record.KeyId, model = record.Model, tokens = record.TotalTokens, mode = record.Mode, ts = record.TimestampUtc, }, ct); } }
See the Production operations page for rate limiting, retry, audit logging, and OpenTelemetry — the other half of the production checklist.