WYSIWYG editing Image gallery upload Content templates
BYOK · per-tenant keys · preview.14

Bring your own key (BYOK)

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.

How it works

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.

The interface

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; }
}

Wiring it up: in-memory reference vault

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");
Tenant resolution. The 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.

Production: Redis-backed vault

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);
Encryption at rest. The snippet above stores plaintext keys in Redis. For PCI / SOC2 / HIPAA workloads, layer IDataProtectionProvider on top — encrypt before StringSetAsync, decrypt after StringGetAsync. The shipped FileBackedAiKeyVault is a good reference for the encrypt/decrypt envelope.

Production: Azure Key Vault

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);
Cost & latency note. Every AI request triggers a Key Vault read. For high-traffic tenants, layer an in-memory cache with a short TTL (60 s) on top — IMemoryCache works fine. Don't cache for hours: a key rotation should propagate to live traffic within a minute or two.

Admin REST endpoints

app.MapRichTextBoxAiKeyVaultAdmin() wires three routes when an IAiKeyVaultAdmin is registered. Wrap them in your own auth. The library does not assume an auth model.

RouteBody / QueryReturns
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

Sample upsert

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"
}

Audit logging

Every key operation emits a structured log entry:

EventIdNameFires when
8204AiKeyVaultMissVault returned Empty + no fallback ApiKey → client sees friendly "AI not configured" message.
8205AiKeyVaultHitVault returned a key. KeyId is logged; the secret never is.
Never log the secret. The library is careful to log only KeyId on hit. Custom vault implementations should follow the same discipline — treat ApiKey as never-loggable.

Per-call cost attribution

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);
    }
}

Need help wiring this up?

See the Production operations page for rate limiting, retry, audit logging, and OpenTelemetry — the other half of the production checklist.