Distributed, Redis-backed access rate limiting for expensive endpoints in ASP.NET Core (.NET 8).
Elf.AccessRateLimit protects heavy endpoints (downloads, exports, reports) from abuse by enforcing rate limits across multiple app instances using Redis as the source of truth. It uses atomic Lua scripts for concurrency safety and supports per-endpoint policies, escalation penalties, and simple extension methods.
Key features:
- Distributed enforcement via StackExchange.Redis
- Per-endpoint policy selection (attribute or endpoint mapping)
- Escalating blocks for repeated violations
- Token-bucket algorithm with atomic Redis Lua script
- Optional headers and custom response body
- Structured logging and optional metrics hooks
- .NET 8
- StackExchange.Redis connection (IConnectionMultiplexer)
NuGet package: https://www.nuget.org/packages/Elf.AccessRateLimit/
dotnet add package Elf.AccessRateLimitOr add a reference in your project file:
<ItemGroup>
<PackageReference Include="Elf.AccessRateLimit" Version="1.0.1" />
</ItemGroup>using StackExchange.Redis;
using Elf.AccessRateLimit;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
builder.Services.AddElfAccessRateLimit(options =>
{
options.DefaultPolicyName = "download";
options.AddPolicy("download", p =>
{
p.WithLimit(10, TimeSpan.FromMinutes(1));
p.WithKeyResolverSpecs("ip", "user");
p.WithPenalty(penalty =>
{
penalty.ViolationWindow = TimeSpan.FromMinutes(10);
penalty.Penalties = new List<TimeSpan>
{
TimeSpan.FromSeconds(10),
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(30)
};
});
});
});
var app = builder.Build();
app.UseElfAccessRateLimit();
app.MapGet("/download/{id}", () => Results.Ok())
.RequireAccessRateLimit("download");
app.Run();{
"Elf": {
"AccessRateLimit": {
"DefaultPolicyName": "download",
"RedisKeyPrefix": "elf:accessrl",
"AddRateLimitHeaders": true,
"FailOpen": true,
"Logging": {
"Detail": "Detailed"
},
"Policies": {
"download": {
"LimitPerMinute": 10,
"KeyStrategy": "ip,user",
"Penalty": {
"ViolationWindow": "00:10:00",
"Penalties": [ "00:00:10", "00:01:00", "00:05:00", "00:30:00" ]
}
}
}
}
}
}builder.Services.AddElfAccessRateLimit(builder.Configuration);By default, AddElfAccessRateLimit(IConfiguration) binds to Elf:AccessRateLimit. To load
settings from a different section, pass the section key explicitly:
builder.Services.AddElfAccessRateLimit("RateLimit");{
"RateLimit": {
"DefaultPolicyName": "download"
}
}Register services:
builder.Services.AddElfAccessRateLimit(options => { /* policies */ });Add the middleware:
app.UseElfAccessRateLimit();Place the middleware after routing (so endpoint metadata is available) and after auth if limits depend on claims. You can also opt into trusted headers to treat requests as authenticated when running behind a gateway.
app.MapGet("/reports/{id}", () => Results.Ok())
.RequireAccessRateLimit("download");You can override the scope or cost:
app.MapGet("/reports/{id}", () => Results.Ok())
.RequireAccessRateLimit("download", scope: "reports", cost: 5);[AccessRateLimit("download", Scope = "reports", Cost = 5)]
[HttpGet("/reports/{id}")]
public IActionResult GetReport(string id) => Ok();options.AddPolicy("export", p =>
{
p.WithLimit(5, TimeSpan.FromMinutes(1));
p.WithSharedBucket("exports");
p.ForAuthenticated(10);
p.ForAnonymous(3);
p.WithAuthenticatedHeaders("X-Client-Id");
p.WithCost(2);
p.WithKeyResolverSpecs("ip", "header:X-Api-Key");
});"Policies": {
"export": {
"Limit": 5,
"Window": "00:01:00",
"SharedBucket": "exports",
"AuthenticatedLimit": 10,
"AnonymousLimit": 3,
"AuthenticatedHeaders": [ "X-Client-Id" ],
"Cost": 2,
"KeyStrategy": "ip,header:X-Api-Key"
}
}By default, authenticated callers are detected via HttpContext.User.Identity.IsAuthenticated.
To trust gateway-provided headers instead, set header names globally or per policy:
options.AuthenticatedHeaders = new List<string> { "X-User-Id" };
options.AddPolicy("download", p =>
{
p.WithAuthenticatedHeaders("X-User-Id");
});Use AuthenticatedWhen in code for custom logic (it overrides the default user/header checks):
options.AuthenticatedWhen = ctx => ctx.User.Identity?.IsAuthenticated == true
|| ctx.Request.Headers.ContainsKey("X-User-Id");Built-in specs:
ip(prefersX-Forwarded-For/X-Real-IP, falls back toRemoteIpAddress)useroruser-id(ClaimTypes.NameIdentifier)subapi-key(X-Api-Key header)client-id(X-Client-Id header)claim:<type>header:<name>
Example:
p.WithKeyResolverSpecs("ip", "claim:tenant_id", "header:X-Api-Key");Custom resolver:
public sealed class CustomResolver : IRateLimitKeyResolver
{
public ValueTask<string?> ResolveAsync(HttpContext context, CancellationToken token = default)
=> new ValueTask<string?>(context.Request.Headers["X-Custom"].ToString());
}
p.WithKeyResolver(new CustomResolver());Use Penalty to increase block duration for repeated violations:
p.WithPenalty(penalty =>
{
penalty.ViolationWindow = TimeSpan.FromMinutes(10);
penalty.Penalties = new List<TimeSpan>
{
TimeSpan.FromSeconds(10),
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(30)
};
});When limited, the middleware returns HTTP 429 with:
Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
Customize the response:
options.Response.ContentType = "application/json";
options.Response.Body = "{\"error\":\"rate_limited\"}";
options.Response.OnRejected = (ctx, decision) =>
{
ctx.Response.ContentType = "application/json";
return ctx.Response.WriteAsync("{\"error\":\"rate_limited\"}");
};Implement and register a metrics handler:
public sealed class RateLimitMetrics : IAccessRateLimitMetrics
{
public void OnAllowed(AccessRateLimitDecision decision) { }
public void OnLimited(AccessRateLimitDecision decision) { }
public void OnBlocked(AccessRateLimitDecision decision) { }
}
builder.Services.AddSingleton<IAccessRateLimitMetrics, RateLimitMetrics>();Choose between normal or detailed logs (information level or higher):
options.Logging.Detail = AccessRateLimitLogDetail.Detailed;Normal logs emit limited/blocked decisions. Detailed logs also include allowed decisions and extra fields.
options.ExemptWhen = ctx =>
ctx.User.IsInRole("Admin") ||
ctx.Connection.RemoteIpAddress?.ToString() == "10.0.0.1";- Redis is the source of truth; optional local caching is not required.
FailOpen = trueallows requests if Redis is unavailable (logged as error).- Use
SharedBucketor per-requestscopeto share a limit across multiple endpoints. Costlets heavy endpoints consume more tokens per request.
The console app Elf.AccessRateLimit.Samples spins up a local in-process WebApplication and exercises the limiter.
dotnet run --project Elf.AccessRateLimit.Samples -- --redis localhost:6379 --sample allSamples available: all, basic, keys, escalation.
- Configuration: options and validation
- Policies: policy models and provider
- Keys: key resolvers and key utilities
- Store: Redis Lua store
- Middleware: rate limit pipeline
- Extensions: service/middleware/endpoint registration
- Metadata: endpoint metadata + attribute
- Metrics: metrics contracts
- Models: decision model
The limiter runs in middleware, resolves a policy per endpoint, builds a stable caller key, and evaluates a token bucket in Redis with an atomic Lua script. The Redis result drives allow/deny, headers, logging, and escalation penalties.
Key points that enforce the limit:
- Policy resolution chooses the endpoint policy or the configured default.
- Key resolution uses policy resolvers (IP, header, claim, composite) and hashes the result before Redis.
- Bucket scope defaults to the route pattern, but can be overridden to share limits.
- Redis Lua script checks blocks, refills tokens, applies cost, and increments violations safely.
- Escalation penalties set a block key with growing TTLs for repeated violations.
- Responses return 429 with Retry-After and optional rate limit headers.