How to Implement Rate Limiting and API Key Security for MCP Servers in ASP.NET Core 10 Using C# (2026)

An MCP server can be overwhelmed by an authenticated client. Authentication answers who sent the request. A rate limiter decides how much that caller may consume. The order matters. Partitioning by an unverified header lets an attacker create arbitrary buckets or impersonate someone else’s quota.

This guide uses a per-caller token bucket and constant-time verification of random API key secrets in ASP.NET Core 10. The key mechanism is an intentionally limited loopback lab. A standards-based remote MCP deployment uses OAuth authorization and verified token identity.

Choose a trusted partition key

The gateway validates a configured key and assigns a stable principal. Only then does the limiter choose the caller partition.
The gateway validates a configured key and assigns a stable principal. Only then does the limiter choose the caller partition.

The header is formatted as reader-a.<random secret>. The ID selects one configured hash. The server hashes the presented secret, compares the result with fixed-time equality, and creates a principal only after verification. The rate limiter sees a stable, finite identity set. Unknown keys never allocate a caller partition in this code.

Control Handles Boundary
Authentication Missing, invalid or expired identity API gateway or app
Authorization Tool or resource access Scope and tenant policy
Token bucket Bursts and sustained request volume Caller quota
Concurrency cap Long-running calls holding memory or sockets Tool or endpoint

A token bucket counts requests over time. An accepted long-running request can still hold a slot well past the refill period. Add concurrency limits and execution deadlines for costly operations. Keep an upstream global/IP abuse policy for invalid-auth traffic. Application quotas cannot protect resources consumed before the application sees the request.

Create the ASP.NET Core 10 lab

Install .NET 10 and OpenSSL. In an empty directory create the two files below. Rate limiting is provided by ASP.NET Core. The only third-party reference is the official HTTP MCP package. See ASP.NET Core rate limiting.

Lab.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup><PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.3.1" /></ItemGroup>
  <ItemGroup><Compile Remove="ServerTests.cs" /></ItemGroup>
  <ItemGroup>
  </ItemGroup>
</Project>
Program.cs
using System.ComponentModel;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.RateLimiting;
using ModelContextProtocol.Server;

var builder = WebApplication.CreateBuilder(args);
var keys = new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
    ["reader-a"] = ReadHash(builder.Configuration["Keys:ReaderA"]),
    ["reader-b"] = ReadHash(builder.Configuration["Keys:ReaderB"])
};
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("mcp", ctx => RateLimitPartition.GetTokenBucketLimiter(
        ctx.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown",
        _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = 6, TokensPerPeriod = 6, AutoReplenishment = true,
            ReplenishmentPeriod = TimeSpan.FromSeconds(10), QueueLimit = 0
        }));
    options.OnRejected = (ctx, _) =>
    {
        if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry))
            ctx.HttpContext.Response.Headers.RetryAfter =
                Math.Ceiling(retry.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
        return ValueTask.CompletedTask;
    };
});
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 16 * 1024);
builder.Services.AddMcpServer().WithHttpTransport().WithTools<QuotaTools>();
var app = builder.Build();
app.UseRouting();
app.Use(async (ctx, next) =>
{
    if (!ctx.Request.Path.StartsWithSegments("/mcp")) { await next(ctx); return; }
    if (ctx.Request.Headers.ContainsKey("Origin") || ctx.Request.Host.Host != "127.0.0.1")
    {
        ctx.Response.StatusCode = 403;
        return;
    }
    var values = ctx.Request.Headers["X-Mcp-Lab-Key"];
    var value = values.ToString();
    var separator = value.IndexOf('.');
    if (values.Count != 1 || value.Length > 180 || separator <= 0 ||
        !keys.TryGetValue(value[..separator], out var hash) ||
        !CryptographicOperations.FixedTimeEquals(hash,
            SHA256.HashData(Encoding.UTF8.GetBytes(value[(separator + 1)..]))))
    {
        ctx.Response.StatusCode = 401;
        return;
    }
    ctx.User = new ClaimsPrincipal(new ClaimsIdentity(
        [new Claim(ClaimTypes.NameIdentifier, value[..separator])], "LocalLabKey"));
    await next(ctx);
});
app.UseRateLimiter();
app.MapMcp("/mcp").RequireRateLimiting("mcp");
await app.RunAsync();

static byte[] ReadHash(string? hex) => hex is { Length: 64 }
    ? Convert.FromHexString(hex) : throw new InvalidOperationException("Set both key hashes.");

[McpServerToolType]
public sealed class QuotaTools
{
    [McpServerTool(Name = "ping_tool", ReadOnly = true)]
    [Description("Return a fixed response.")]
    public static string Ping() => "pong";
}

The six-token bucket replenishes six tokens every ten seconds. Queueing is disabled, so excess calls fail quickly. UseRouting selects the endpoint before middleware applies its named rate policy. The key middleware runs before UseRateLimiter so it can populate the caller identity. A limit rejection is HTTP 429 and includes Retry-After when the lease supplies that metadata. The token bucket is in memory and applies separately to each server replica.

Generate two independent lab identities

Generate random keys and configure only their hashes
export KEY_A="$(openssl rand -hex 32)"
export KEY_B="$(openssl rand -hex 32)"
export Keys__ReaderA="$(printf '%s' "$KEY_A" | openssl dgst -sha256 -r | cut -d' ' -f1)"
export Keys__ReaderB="$(printf '%s' "$KEY_B" | openssl dgst -sha256 -r | cut -d' ' -f1)"
dotnet run --no-launch-profile -- --urls http://127.0.0.1:5080

Run the test client in a shell where the corresponding random key is available. Treat the keys and hashes as sensitive operational data. A fast SHA-256 check is appropriate for the lab’s high-entropy randomly generated secrets. Human passwords require a password-hashing scheme designed to resist guessing. A production key service should support expiry, scoped access, revocation and rotation.

Test authentication and throttling

The following request exercises a legitimate MCP tools/list call with the July 2026 headers and body metadata. Run locally. Avoid printing credentials in debugging output or putting them into a public report.

Send a modern Streamable HTTP request
probe() {
  curl -sS -o /dev/null -w '%{http_code}\n' \
    -H "X-Mcp-Lab-Key: $1" \
    -H 'Accept: application/json, text/event-stream' \
    -H 'Content-Type: application/json' \
    -H 'MCP-Protocol-Version: 2026-07-28' \
    -H 'Mcp-Method: tools/list' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
    http://127.0.0.1:5080/mcp
}
probe 'reader-a.wrong'
for i in 1 2 3 4 5 6 7 8; do probe "reader-a.$KEY_A"; done
probe "reader-b.$KEY_B"

Expect 401 for a bad secret, a run of successful responses followed by 429 for caller A if the test fits inside one refill window, and a successful call for caller B. For a test of the tool itself, use an MCP client supporting a custom lab header and call ping_tool. An SDK may spend quota on capability discovery and listing as well as the final invocation. Count the HTTP requests you actually want to limit. The wire examples are based on the Streamable HTTP transport specification.

The project compiled on .NET SDK 10.0.100. The commands above describe expected quota behavior.

Move from the lab to a remote MCP service

Use the MCP authorization specification for compatible remote clients. Publish the applicable discovery metadata, validate token issuer and audience, and enforce the scopes needed for a specific tool. Derive a partition key from verified application or user identity. When combining tenant and caller IDs, canonicalize both and avoid allowing arbitrary unbounded values.

A reverse proxy may throttle unauthenticated traffic before it reaches the API. If it partitions by IP, configure trusted proxy forwarding explicitly so a client cannot forge its own address. If quota must apply across replicas, enforce it in a shared gateway or a distributed limiter with well-defined failure behavior. In-memory ASP.NET Core limiters do not synchronize across machines.

Symptom Check
All callers share one quota Verify authentication runs before the limiter and claims are present
Limit differs across replicas Each replica has its own in-memory bucket
Normal client receives unexpected 429 Count discovery and listing requests
Invalid keys saturate CPU or connections Apply coarse admission control before application authentication
Retried calls duplicate effects Use a scoped idempotency key and retry only when safe

FAQ and references

Should I expose a private API key header as remote MCP OAuth? The private header only works with clients that know that convention. OAuth discovery, token issuance and validation are separate requirements.

Should I retry 429 immediately? Respect the retry hint where available. Add bounded backoff and jitter for safe operations. Automatic retries of writes require a separate idempotency design.

Can a client choose its quota partition? It can present credentials. The server determines the partition only from authenticated identity.

Start with separate authentication, authorization and quota policies. Test two caller identities and denial paths before moving the limiter into a scaled deployment.

Comments