Learn ASP.NET Core rate limiting with built-in .NET middleware. Fixed window, sliding window, token bucket & code examples. Secure your API today!
If your API is public, it will be abused — by scrapers, misconfigured clients stuck in retry loops, brute-force login bots, or a single power user hammering an expensive endpoint. ASP.NET Core rate limiting is the first line of defense, and since .NET 7 it has been built directly into the framework via the Microsoft.AspNetCore.RateLimiting middleware — no third-party packages required. In this tutorial you'll learn how to configure all four built-in rate limiting algorithms in .NET 8, 9, and 10, how to apply limits per user or per IP address, how to return proper 429 Too Many Requests responses, and the pitfalls that trip up production deployments (especially behind load balancers).
Why Rate Limiting Matters in 2026
Rate limiting is not just a security checkbox. It solves several distinct problems, and knowing which problem you're solving determines which algorithm you should pick:
- Abuse prevention: credential stuffing, scraping, and enumeration attacks all depend on making thousands of requests quickly. A per-IP limit on
/loginmakes brute-forcing impractical. - Cost control: if an endpoint calls a paid LLM API or triggers an expensive database query, an unthrottled client can burn real money in minutes.
- Fair usage: one tenant in a SaaS application shouldn't be able to starve everyone else. Partitioned limits guarantee each customer a fair share.
- Stability under load: a concurrency limiter caps how many requests run simultaneously, protecting downstream resources like database connection pools.
The alternative — letting the server fall over and returning 500s to everyone — is strictly worse than returning 429s to the noisy few. That's the core principle: shed load early, deterministically, and politely.
ASP.NET Core Rate Limiting: The Built-In Middleware
The built-in middleware lives in the framework itself (backed by the System.Threading.RateLimiting package) and is wired up with two calls: AddRateLimiter to define policies and UseRateLimiter to activate the middleware. Here's a complete, runnable minimal API example:
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
// Return 429 instead of the default 503 when a request is rejected
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Named policy: 100 requests per minute, fixed window
options.AddFixedWindowLimiter("api", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 5; // hold up to 5 requests instead of rejecting
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/products", () => Results.Ok(new[] { "Laptop", "Phone" }))
.RequireRateLimiting("api");
app.Run();
Send 101 requests within a minute and the 101st gets a 429. With controllers, you apply the same policy declaratively:
[EnableRateLimiting("api")]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult GetOrders() => Ok();
// Opt a specific action out of the controller-level policy
[DisableRateLimiting]
[HttpGet("health")]
public IActionResult Health() => Ok("healthy");
}
The Four Rate Limiting Algorithms (and When to Use Each)
1. Fixed Window — Simple and Predictable
The fixed window limiter allows N requests per time window, then resets the counter when the window rolls over. It's the easiest to reason about and the cheapest to run. The catch is the boundary burst problem: a client can send 100 requests at 11:59:59 and another 100 at 12:00:01 — 200 requests in two seconds while technically staying within limits. For most CRUD APIs this is acceptable; for expensive endpoints it isn't.
2. Sliding Window — Smoother Enforcement
The sliding window splits each window into segments and counts requests across a rolling range, which eliminates the boundary burst. Use it when you want the simplicity of "N per minute" but can't tolerate double-bursts at window edges:
options.AddSlidingWindowLimiter("sliding", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 6; // 10-second segments
limiterOptions.QueueLimit = 0;
});
3. Token Bucket — Bursts Allowed, Average Enforced
The token bucket algorithm is the industry favorite (it's what most cloud providers use). The bucket holds a maximum number of tokens; each request spends one, and tokens are replenished at a steady rate. This allows short bursts (up to the bucket size) while enforcing a long-term average rate — ideal for real client behavior, which is naturally bursty:
options.AddTokenBucketLimiter("token", limiterOptions =>
{
limiterOptions.TokenLimit = 50; // burst capacity
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
limiterOptions.TokensPerPeriod = 10; // 1 token/sec average
limiterOptions.AutoReplenishment = true;
});
4. Concurrency Limiter — Protect What's Behind You
Unlike the other three, the concurrency limiter doesn't care about time at all — it caps how many requests execute at the same moment. This is the right tool for endpoints that hold expensive resources open (report generation, file processing, database-heavy queries):
options.AddConcurrencyLimiter("heavy", limiterOptions =>
{
limiterOptions.PermitLimit = 10; // max 10 in flight
limiterOptions.QueueLimit = 20; // 20 more may wait
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
Per-User and Per-IP Rate Limiting with Partitioned Limiters
A single shared counter means one abusive client exhausts the limit for everyone — usually the opposite of what you want. Partitioned limiters give each client their own bucket. This global limiter partitions by authenticated user, falling back to IP address for anonymous traffic:
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
? httpContext.User.Identity.Name!
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
});
});
This pattern also unlocks tiered limits — a classic SaaS requirement. Give premium customers a bigger bucket by branching on a claim:
return httpContext.User.HasClaim("tier", "premium")
? RateLimitPartition.GetTokenBucketLimiter(partitionKey, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 500,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
TokensPerPeriod = 100,
AutoReplenishment = true
})
: RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 50,
Window = TimeSpan.FromMinutes(1)
});
Return Helpful 429 Responses with Retry-After
A bare 429 forces well-behaved clients to guess when to retry. The OnRejected callback lets you attach a Retry-After header and a readable body, which polite HTTP clients (including HttpClient resilience handlers and Polly) respect automatically:
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();
}
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "Too many requests. Please slow down.",
retryAfterSeconds = retryAfter.TotalSeconds
}, cancellationToken);
};
Common Pitfalls in Production
The in-memory trap: limits are per server instance
This is the pitfall that bites almost everyone. The built-in limiters store counters in the memory of each application instance. Run three replicas behind a load balancer with a 100/minute limit, and clients effectively get up to 300/minute — and inconsistently, depending on routing. For strict distributed enforcement you need a shared store: enforce limits at the gateway (YARP, Azure API Management, AWS API Gateway, Cloudflare) or use a Redis-backed limiter such as the RedisRateLimiting community package. A pragmatic middle ground: treat in-process limits as per-instance protection and divide your global budget by the replica count.
Partitioning by IP behind a proxy
Behind a load balancer or CDN, RemoteIpAddress is the proxy's address — so every user shares one partition and you rate-limit your entire user base as a single client. Configure ForwardedHeadersMiddleware (with KnownProxies set, so attackers can't spoof X-Forwarded-For) before the rate limiter, and register UseRateLimiter after UseRouting so endpoint-specific policies resolve correctly.
Queueing surprises
A non-zero QueueLimit holds rejected requests instead of failing them, which adds latency and ties up server resources under attack. For public endpoints, prefer QueueLimit = 0 and fail fast; reserve queues for internal or concurrency-limited endpoints. Also note QueueProcessingOrder.NewestFirst will starve the oldest waiting requests — OldestFirst is almost always what you want.
Forgetting to exempt health checks
If your Kubernetes liveness probe gets rate limited, the orchestrator restarts a perfectly healthy pod. Always mark health and readiness endpoints with DisableRateLimiting or exclude them from the global limiter's partition logic.
Best Practices Checklist
- Set
RejectionStatusCodeto 429 — the default 503 misleads clients and monitoring into thinking the server is down. - Always send
Retry-Afterso well-behaved clients back off automatically instead of retrying immediately. - Partition by identity first, IP second. IPs are shared by corporate NATs and mobile carriers; API keys and user IDs are fairer.
- Match the algorithm to the endpoint: token bucket for general APIs, fixed window for login/OTP endpoints, concurrency limiter for expensive operations.
- Log and monitor rejections. A spike in 429s is an early-warning signal for an attack — or a sign your limits are too tight for legitimate traffic.
- Layer your defenses: coarse limits at the CDN/gateway, fine-grained per-user policies in ASP.NET Core.
- Load-test your limits with a tool like k6 or NBomber before shipping — guessing PermitLimit values in production is how you throttle paying customers.
Conclusion: Key Takeaways
ASP.NET Core rate limiting in 2026 requires no external packages: the built-in middleware ships with fixed window, sliding window, token bucket, and concurrency limiters that cover the vast majority of real-world needs. Register policies with AddRateLimiter, activate them with UseRateLimiter, and attach them to endpoints with RequireRateLimiting or [EnableRateLimiting].
The essentials to remember:
- Use token bucket as your default algorithm — it tolerates natural bursts while enforcing a fair average rate.
- Partition limits per user or per IP so one abusive client can't lock everyone out.
- Return 429 with a
Retry-Afterheader, never a bare 503. - Remember that built-in limiters are per instance — plan for a gateway or Redis when you scale out.
- Exempt health checks, configure forwarded headers, and load-test your thresholds before production.
Add a rate limiter to one endpoint today — the login route is the highest-value place to start — and expand from there. Your infrastructure bill, your on-call rotation, and your legitimate users will all thank you.
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