Learn ASP.NET Core rate limiting with fixed window, sliding window, token bucket and concurrency limiters. Runnable C# examples — secure your API today.
If your API is public, it will get hammered — by bots, by misconfigured clients stuck in retry loops, and occasionally by someone actively trying to knock it over. ASP.NET Core rate limiting is the built-in defense for this. Since .NET 7, the Microsoft.AspNetCore.RateLimiting middleware ships in the framework, and in 2026 with .NET 9 and .NET 10 it's mature, fast, and flexible enough that you no longer need a third-party package for most scenarios.
In this guide you'll learn what rate limiting actually protects you from, how the four built-in algorithms differ (and which one to choose), and how to wire up a production-ready configuration with per-user partitions, proper 429 responses, and the pitfalls that trip up most teams.
Why Rate Limiting Matters for API Security
Rate limiting caps how many requests a client can make in a given time window. It's not a replacement for authentication or a WAF, but it solves a distinct set of problems:
- Brute-force and credential stuffing — limiting
/loginto a handful of attempts per minute makes password guessing impractical. - Resource exhaustion — one buggy client polling every 50 ms can starve every other user of database connections and thread-pool threads.
- Cost control — if an endpoint calls a paid downstream service (an LLM API, a geocoder, SMS), unlimited calls mean unlimited invoices.
- Fair usage — free-tier and paid-tier customers get predictable quotas instead of a first-come-first-served free-for-all.
- Graceful degradation — under load, rejecting a fraction of requests quickly with
429 Too Many Requestsis far better than every request timing out.
Getting Started with ASP.NET Core Rate Limiting
The middleware lives in the shared framework — no NuGet package needed. Registration takes two calls: AddRateLimiter to define policies and UseRateLimiter to add the middleware to the pipeline.
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
// Status code returned when a request is rejected (default is 503!)
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 100; // 100 requests...
limiterOptions.Window = TimeSpan.FromMinutes(1); // ...per minute
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0; // reject immediately, don't queue
});
});
var app = builder.Build();
app.UseRateLimiter(); // must come after UseRouting, before endpoints
app.MapGet("/api/products", () => Results.Ok(new[] { "Laptop", "Phone" }))
.RequireRateLimiting("fixed");
app.Run();
Run this and hit /api/products 101 times inside a minute — request 101 gets a 429. Two details are worth calling out immediately, because they're the first things people get wrong:
- The default rejection status is 503, not 429. Always set
RejectionStatusCodeexplicitly — clients and CDNs treat 429 and 503 very differently. - Middleware order matters.
UseRateLimiter()must be called afterUseRouting()(implicit in minimal APIs) so it can see endpoint metadata, and afterUseAuthentication()if your partition key depends on the user identity.
The Four Built-In Rate Limiting Algorithms
ASP.NET Core ships four limiters. Picking the right one is the main design decision, so it's worth understanding how each behaves.
1. Fixed Window
Divides time into fixed buckets (e.g. each calendar minute) and allows N requests per bucket. It's the simplest and cheapest, but it has a well-known flaw: a client can make 100 requests at 11:59:59 and another 100 at 12:00:00 — 200 requests in two seconds while technically staying within "100 per minute."
2. Sliding Window
Fixes the burst-at-the-boundary problem by splitting the window into segments and counting requests over the trailing window. Slightly more memory per partition, much smoother enforcement. This is the best default for most public endpoints.
options.AddSlidingWindowLimiter("sliding", o =>
{
o.PermitLimit = 100;
o.Window = TimeSpan.FromMinutes(1);
o.SegmentsPerWindow = 6; // 10-second segments
o.QueueLimit = 0;
});
3. Token Bucket
A bucket holds up to N tokens; every request spends one; tokens are refilled at a steady rate. This lets clients burst up to the bucket capacity while enforcing a long-run average. It's the algorithm most commercial APIs (Stripe, GitHub, AWS) use, because it matches real traffic shapes — quiet periods followed by short bursts.
options.AddTokenBucketLimiter("token", o =>
{
o.TokenLimit = 50; // max burst
o.TokensPerPeriod = 10; // refill amount
o.ReplenishmentPeriod = TimeSpan.FromSeconds(5); // ...every 5 seconds = 2 req/s sustained
o.AutoReplenishment = true;
o.QueueLimit = 5; // allow a small wait queue
o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
4. Concurrency
Different from the others: it limits how many requests are in flight at the same time, not how many arrive per period. Use it to protect an expensive operation — report generation, PDF rendering, a call to a slow legacy system — from being executed more than, say, 10 times simultaneously.
options.AddConcurrencyLimiter("reports", o =>
{
o.PermitLimit = 10; // max 10 simultaneous executions
o.QueueLimit = 20; // up to 20 more wait their turn
o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
Which one should you use?
- Login, password reset, OTP → Fixed window with a tiny limit (5 per 15 minutes). Simplicity wins; bursts aren't a concern because the limit is so low.
- General public API → Sliding window, or token bucket if you want documented burst allowances.
- CPU/IO-heavy endpoints → Concurrency limiter, often in addition to a per-user rate limit.
Partitioning: Rate Limiting Per User, Per IP, or Per API Key
The examples above apply one shared limit to everyone. That's rarely what you want — one abusive client would lock out all your legitimate users. Real-world ASP.NET Core rate limiting is almost always partitioned: each user, IP, or API key gets its own counter.
AddPolicy with a partition function gives you full control. Here's a policy that gives authenticated users a generous per-user limit and falls back to a strict per-IP limit for anonymous traffic:
options.AddPolicy("per-user", httpContext =>
{
var user = httpContext.User;
if (user.Identity?.IsAuthenticated == true)
{
var userId = user.FindFirst("sub")?.Value ?? user.Identity.Name!;
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: $"user:{userId}",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 200,
TokensPerPeriod = 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
AutoReplenishment = true,
QueueLimit = 0
});
}
// Anonymous: partition by client IP, much tighter
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: $"ip:{ip}",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0
});
});
Because the partition function receives the full HttpContext, you can key on anything — an X-Api-Key header, a tenant claim, a route value. A common pattern for SaaS products is reading a tier claim and returning a different limiter for free vs. enterprise customers.
A word on client IP behind proxies
If your app sits behind a load balancer, Nginx, Cloudflare, or Azure Front Door, RemoteIpAddress will be the proxy's address, and every user will share one partition. Configure forwarded headers before the rate limiter:
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
o.KnownProxies.Add(IPAddress.Parse("10.0.0.5")); // trust only your proxy
});
app.UseForwardedHeaders();
app.UseRateLimiter();
Never trust X-Forwarded-For from arbitrary sources — an attacker can spoof it and get a fresh partition per request, bypassing your limit entirely.
Global Limiters and Chained Policies
Rather than annotating every endpoint, you can set a GlobalLimiter that applies to all requests, then layer stricter per-endpoint policies on top. PartitionedRateLimiter.CreateChained combines several limiters — a request must pass all of them.
options.GlobalLimiter = PartitionedRateLimiter.CreateChained(
// Layer 1: per-IP, coarse
PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 600,
Window = TimeSpan.FromMinutes(1)
})),
// Layer 2: whole-server ceiling, protects the box regardless of source
PartitionedRateLimiter.Create<HttpContext, string>(_ =>
RateLimitPartition.GetConcurrencyLimiter(
"global",
_ => new ConcurrencyLimiterOptions { PermitLimit = 500, QueueLimit = 100 })));
To exempt an endpoint (a health check, for example), use .DisableRateLimiting(). In MVC controllers the equivalents are [EnableRateLimiting("policy")] and [DisableRateLimiting].
app.MapGet("/health", () => Results.Ok()).DisableRateLimiting();
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("per-user")]
public class OrdersController : ControllerBase
{
[HttpPost]
[EnableRateLimiting("strict")] // overrides the class-level policy
public IActionResult Create(OrderDto dto) => Ok();
}
Returning a Useful 429 Response
A bare 429 leaves the client guessing. Well-behaved SDKs look for a Retry-After header and back off accordingly. The OnRejected callback lets you set it and return a structured body:
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
context.HttpContext.Response.ContentType = "application/problem+json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc6585#section-4",
title = "Too Many Requests",
status = 429,
detail = "Rate limit exceeded. Slow down and retry after the indicated delay."
}, cancellationToken);
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiting");
logger.LogWarning("Rate limit hit for {Path} from {IP}",
context.HttpContext.Request.Path,
context.HttpContext.Connection.RemoteIpAddress);
};
Note that RetryAfter metadata is only populated by window-based limiters (fixed and sliding) and token bucket — the concurrency limiter has no concept of "when a slot frees up."
Best Practices for Rate Limiting in ASP.NET Core
- Set
QueueLimit = 0on public endpoints. Queued requests hold a connection and a thread-pool continuation while they wait. Under a flood, a large queue turns your rate limiter into a memory sink. Reserve queues for concurrency limiters on internal, trusted traffic. - Prefer sliding window or token bucket over fixed window for anything with a limit above ~10 requests — the boundary-burst problem is real.
- Always partition. A single un-partitioned limiter is a self-inflicted denial of service waiting to happen.
- Put the limiter after authentication if your partition key uses claims, but consider a second, IP-based global limiter before auth so that expensive token validation itself is protected.
- Load-test your limits. Tools like
k6orbombardierwill show you whether 100 req/min is generous or crippling for your real clients. Guessing numbers is how you end up throttling your own mobile app. - Document limits in your API reference and expose them via headers (
X-RateLimit-Limit,X-RateLimit-Remaining) so client developers can build sensible retry logic. - Read limits from configuration, not constants, so you can tune them without a redeploy.
Common Pitfalls
It only works per server instance
This is the big one. The built-in limiters store their state in memory. If you run three pods behind a load balancer, each pod enforces its own copy of the limit, so a "100 per minute" policy effectively becomes 300 per minute — and which pod a client hits is random. For a single instance, or where approximate limits are acceptable, the built-in middleware is fine. For strict, distributed enforcement you need a shared store: Redis-backed options such as the community RedisRateLimiting package, or enforcing limits at the gateway layer (YARP, Azure API Management, Kong, Cloudflare) where state is already centralized.
Forgetting the default 503
Mentioned above, but it bites everyone once. Set RejectionStatusCode = 429.
Rate limiting behind a proxy without forwarded headers
All traffic appears to come from one IP, so every user shares one partition. Configure UseForwardedHeaders with a known-proxy list.
Applying limits to health checks and metrics endpoints
Your Kubernetes liveness probe hitting a 429 will restart a perfectly healthy pod. Exempt infrastructure endpoints explicitly.
Partition key cardinality
Each unique partition key allocates a limiter object that lives in memory. Keying on something unbounded — a random query parameter, a full URL with IDs — will grow memory without limit. Idle partitions are cleaned up, but only for limiters that report themselves idle; stick to bounded keys like user IDs and IPs.
Conclusion: Key Takeaways on ASP.NET Core Rate Limiting
ASP.NET Core rate limiting gives you a production-quality defense against API abuse with zero external dependencies. To get it right:
- Register with
AddRateLimiter/UseRateLimiter, and set the rejection code to 429. - Choose the algorithm to match the endpoint: fixed window for tiny auth limits, sliding window or token bucket for general traffic, concurrency for expensive operations.
- Partition by user, API key, or IP — never share one counter across all clients.
- Configure forwarded headers behind proxies and keep
QueueLimitat zero for public traffic. - Return
Retry-Afterand a Problem Details body fromOnRejectedso clients can back off intelligently. - Remember that state is per-instance; go to Redis or the gateway when you scale out and need exact limits.
Implement these patterns and your API will shrug off the retry storms, scrapers, and brute-force attempts that would otherwise take it down — while your legitimate users never notice a thing.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment