.NET · C# · ASP.NET Core · AI agents · API key management

.NET C# AI agent API key management: vault keys via ASP.NET Core middleware and scoped DI

ASP.NET Core's DI container registers vendor API clients as singletons by default — builder.Services.AddSingleton<StripeClient>(...). A single StripeClient instance serves all HTTP requests in the application's lifetime. For AI agent tool endpoints, this means every concurrent agent invocation shares the same Stripe key: no per-agent spend cap, no independent revocation, and no per-run audit attribution. ASP.NET Core's scoped service lifetime — one instance per HTTP request — is the natural fit for vault keys: register a VaultKeyAccessor as scoped, populate it from middleware, and inject it into agent controllers.

TL;DR

Register VaultKeyService as a singleton (it's stateless) and VaultKeyAccessor as scoped (it holds the per-request token). Write an ASP.NET Core middleware that calls VaultKeyService.IssueAsync(), sets the result on the scoped VaultKeyAccessor, and revokes in a try/finally after next(context) returns. Inject VaultKeyAccessor into agent controllers and use its Token property for Keybrake proxy calls. For Hangfire background jobs, issue the vault key at the start of the job method and revoke in a finally block.

The ASP.NET Core AI agent tool backend pattern

The standard registration binds Stripe once at application start:

// Program.cs
using Stripe;

var builder = WebApplication.CreateBuilder(args);

// Singleton — one StripeClient shared across ALL requests
builder.Services.AddSingleton<StripeClient>(sp =>
    new StripeClient(builder.Configuration["Stripe:SecretKey"]));

builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();

Every concurrent agent tool call shares StripeClient's API key. Stripe sees the same authenticating key for every request from every agent.

Scoped VaultKeyAccessor and middleware

Use ASP.NET Core's scoped lifetime — one instance per HTTP request, automatically disposed at request end — for the per-request vault key:

// Services/VaultKeyAccessor.cs
namespace AgentBackend.Services;

public class VaultKeyAccessor
{
    public string? Token { get; set; }
    public string? KeyId  { get; set; }
    public bool    IsSet  => Token is not null;
}
// Services/VaultKeyService.cs
namespace AgentBackend.Services;

public class VaultKeyService
{
    private readonly HttpClient _http;
    private readonly string _keybrakToken;

    public VaultKeyService(IHttpClientFactory factory, IConfiguration config)
    {
        _http         = factory.CreateClient("keybrake");
        _keybrakToken = config["Keybrake:Token"]!;
    }

    public async Task<(string Id, string Token)?> IssueAsync(
        string label,
        string vendor,
        int    dailyCapUsd,
        string[] allowedEndpoints,
        string expiresIn,
        CancellationToken ct = default)
    {
        var payload = new
        {
            label,
            vendor,
            daily_usd_cap     = dailyCapUsd,
            allowed_endpoints = allowedEndpoints,
            expires_in        = expiresIn,
        };

        using var resp = await _http.PostAsJsonAsync("/v1/keys", payload, ct);
        if (!resp.IsSuccessStatusCode)
            return null; // Fail open

        var body = await resp.Content.ReadFromJsonAsync<VaultKeyResponse>(cancellationToken: ct);
        return body is null ? null : (body.Id, body.Token);
    }

    public async Task RevokeAsync(string keyId)
    {
        await _http.DeleteAsync($"/v1/keys/{keyId}");
        // Non-critical — TTL is safety net; ignore errors
    }

    private record VaultKeyResponse(string Id, string Token);
}
// Middleware/VaultKeyMiddleware.cs
namespace AgentBackend.Middleware;

using AgentBackend.Services;

public class VaultKeyMiddleware
{
    private readonly RequestDelegate _next;

    public VaultKeyMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(
        HttpContext context,
        VaultKeyService service,
        VaultKeyAccessor accessor)
    {
        var userId = context.Request.Headers["X-User-ID"].FirstOrDefault() ?? "unknown";
        var runId  = context.Request.Headers["X-Agent-Run-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
        var label  = $"dotnet-{userId}-{runId}";

        var result = await service.IssueAsync(
            label:            label,
            vendor:           "stripe",
            dailyCapUsd:      500,
            allowedEndpoints: ["/v1/payment_intents", "/v1/payment_intents/*"],
            expiresIn:        "5m",
            ct:               context.RequestAborted);

        if (result is not null)
        {
            accessor.KeyId = result.Value.Id;
            accessor.Token = result.Value.Token;
        }

        try
        {
            await _next(context);
        }
        finally
        {
            if (accessor.KeyId is not null)
                await service.RevokeAsync(accessor.KeyId);
        }
    }
}

Registration in Program.cs

// Program.cs (extended)
builder.Services.AddHttpClient("keybrake", client => {
    client.BaseAddress = new Uri("https://api.keybrake.com");
    client.DefaultRequestHeaders.Authorization =
        new System.Net.Http.Headers.AuthenticationHeaderValue(
            "Bearer", builder.Configuration["Keybrake:Token"]);
    client.Timeout = TimeSpan.FromSeconds(3);
});

builder.Services.AddSingleton<VaultKeyService>();
builder.Services.AddScoped<VaultKeyAccessor>(); // scoped = per-request

var app = builder.Build();

// Apply vault middleware to agent tool routes only
app.UseWhen(
    ctx => ctx.Request.Path.StartsWithSegments("/api/agent/tools"),
    branch => branch.UseMiddleware<VaultKeyMiddleware>()
);

app.MapControllers();

Agent tool controller using the vault key

// Controllers/AgentToolsController.cs
[ApiController]
[Route("api/agent/tools")]
public class AgentToolsController : ControllerBase
{
    private readonly VaultKeyAccessor _accessor;
    private readonly IHttpClientFactory _httpFactory;

    public AgentToolsController(VaultKeyAccessor accessor, IHttpClientFactory httpFactory)
    {
        _accessor   = accessor;
        _httpFactory = httpFactory;
    }

    [HttpPost("charge")]
    public async Task<IActionResult> Charge([FromBody] ChargeRequest req)
    {
        if (!_accessor.IsSet)
            return StatusCode(503, new { error = "vault_key_unavailable" });

        var http = _httpFactory.CreateClient();
        var body = new { amount = req.Amount, currency = "usd" };

        var msg = new HttpRequestMessage(HttpMethod.Post,
            "https://proxy.keybrake.com/stripe/v1/payment_intents")
        {
            Content = JsonContent.Create(body),
        };
        msg.Headers.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessor.Token);

        using var resp = await http.SendAsync(msg);

        if (resp.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
        {
            var err = await resp.Content.ReadFromJsonAsync<ErrorBody>();
            if (err?.Code == "cap_exhausted")
                return StatusCode(402, new { error = "spend_cap_exceeded" });
        }

        var result = await resp.Content.ReadFromJsonAsync<object>();
        return StatusCode((int)resp.StatusCode, result);
    }

    private record ChargeRequest(int Amount);
    private record ErrorBody(string Code);
}

Microsoft Semantic Kernel integration

For agents built with Microsoft Semantic Kernel, the vault key can be passed via a KernelFunction plugin that reads from the scoped VaultKeyAccessor:

using Microsoft.SemanticKernel;

public class StripePlugin
{
    private readonly VaultKeyAccessor _accessor;
    private readonly HttpClient _http;

    public StripePlugin(VaultKeyAccessor accessor, IHttpClientFactory factory)
    {
        _accessor = accessor;
        _http     = factory.CreateClient();
    }

    [KernelFunction("create_payment")]
    [Description("Creates a Stripe payment intent for the given amount in cents")]
    public async Task<string> CreatePaymentAsync(int amountCents)
    {
        if (!_accessor.IsSet)
            throw new InvalidOperationException("Vault key not available");

        var msg = new HttpRequestMessage(HttpMethod.Post,
            "https://proxy.keybrake.com/stripe/v1/payment_intents")
        {
            Content = JsonContent.Create(new { amount = amountCents, currency = "usd" }),
        };
        msg.Headers.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessor.Token);

        using var resp = await _http.SendAsync(msg);
        return await resp.Content.ReadAsStringAsync();
    }
}
.NET contextDI lifetime for VaultKeyAccessorRevocation point
ASP.NET Core controller action Scoped (per-request) — automatic with DI Middleware finally after _next(context)
Minimal API endpoint Scoped — inject via endpoint handler parameter Middleware finally — same pattern
Hangfire background job N/A — no request scope; instantiate manually finally block in Execute() or job method
Worker Service (IHostedService) Create a per-iteration scope with IServiceScopeFactory.CreateScope() Dispose scope after each agent iteration

Get early access

Related questions

Does this work with Hangfire background jobs?

Hangfire jobs run outside of the HTTP request pipeline, so there's no ASP.NET Core request scope. The VaultKeyAccessor scoped pattern doesn't apply. Instead, issue the vault key at the beginning of the job method using a directly resolved VaultKeyService (inject it as a constructor parameter — it's a singleton, so this works fine), and revoke in a finally block. Alternatively, create a dedicated IServiceScope inside the job using IServiceScopeFactory and resolve both services from the scoped container — this gives you the same lifetime isolation as a request scope, just manually managed. Hangfire's job activation can also be configured to use ASP.NET Core's DI directly, in which case each job activation creates a scope automatically if you register Hangfire with AddHangfire(...).UseActivator(new AspNetCoreJobActivator(serviceProvider)).

How does this interact with ASP.NET Core's IHttpClientFactory and Polly retry policies?

The vault key's spend cap is a server-side enforcement — Polly retry policies will retry on 429 responses by default, which means a retry on a cap_exhausted 429 will keep failing until the cap resets. Configure your Polly policy to distinguish between transient 429s (rate limits, retryable) and cap_exhausted 429s (non-retryable): check the response body for "code": "cap_exhausted" and throw a non-retryable exception type if present. In Polly v8 with ResiliencePipeline, use ShouldHandle = args => ... to inspect the response before deciding to retry. This prevents retry loops from compounding spend beyond the cap.

Should I use AddSingleton or AddScoped for VaultKeyService?

VaultKeyService should be AddSingleton — it's stateless (it only makes HTTP calls and has no per-request state). The per-request state lives in VaultKeyAccessor, which is AddScoped. Registering VaultKeyService as singleton means the same HttpClient instance (from IHttpClientFactory) is reused across requests, which is the correct pattern for HttpClient in .NET (avoids socket exhaustion from creating new instances per request). VaultKeyAccessor is scoped because it holds the per-request vault token — it must be isolated to one request scope, not shared across concurrent requests.

Further reading