Updated November 2026: updated for .NET 10. I fixed two mistakes of the first version (rejected requests now really get a 429, and attributes do not limit every request on their own), and added per-client limits, a global limiter and Minimal APIs.
In this article, we look at the built-in rate limiting middleware of ASP.NET Core, available since .NET 7: its algorithms, and how to apply it to controllers and Minimal APIs.
What is Rate Limiting?
Rate limiting controls how many requests a client can make within a given time. It protects an API from denial of service, brute force and other abuse. The OWASP API Security Top 10 lists its absence as Unrestricted Resource Consumption.
Rate Limiting middleware in ASP.NET Core
Before .NET 7, rate limiting required a third-party package such as AspNetCoreRateLimit. It is now built in, in two namespaces:
System.Threading.RateLimitingholds the limiters (FixedWindowRateLimiter,PartitionedRateLimiter...) and their options. They are not tied to HTTP.Microsoft.AspNetCore.RateLimitingholds the middleware (AddRateLimiter,UseRateLimiter,RateLimiterOptions) and the[EnableRateLimiting]/[DisableRateLimiting]attributes. It ships with ASP.NET Core: there is no package to install.
Configure Rate Limiting middleware
Register the services, then add the middleware to the pipeline with UseRateLimiter:
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
// Configure the policies here
});
app.UseRateLimiter();Note: When limits are attached to endpoints, as below,
UseRateLimiter()must run after routing.WebApplicationadds routing at the start of the pipeline unless you callUseRouting()yourself, so callingUseRateLimiter()beforeMapControllers()is enough.
Rate Limiter Algorithms
Each Add...Limiter call below registers a named policy with one of the four algorithms.
Fixed Window
This algorithm divides time into fixed windows and allows a number of requests in each:
options.AddFixedWindowLimiter("fixed", options =>
{
options.PermitLimit = 10;
options.Window = TimeSpan.FromSeconds(10);
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 5;
});Each 10-second window (Window) allows 10 requests (PermitLimit). Up to 5 more wait in a queue (QueueLimit) until the next window starts. Any request beyond that is rejected.
Sliding Window
This algorithm also uses a window, but divides it into segments:
options.AddSlidingWindowLimiter("sliding", options =>
{
options.PermitLimit = 10;
options.Window = TimeSpan.FromSeconds(10);
options.SegmentsPerWindow = 2;
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 5;
});Here the 10-second window has 2 segments of 5 seconds. Every 5 seconds, the window slides forward by one segment, so the limit applies to the last 10 seconds rather than to a fixed period. This avoids the burst a fixed window allows at the boundary between two windows.
Token Bucket
This algorithm adds tokens to a bucket at a steady rate:
options.AddTokenBucketLimiter("token", options =>
{
options.TokenLimit = 100;
options.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
options.TokensPerPeriod = 20;
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 5;
});Each request takes a token; when the bucket is empty, requests are queued or rejected. Unused tokens accumulate up to TokenLimit, which allows occasional bursts while limiting the overall rate.
Concurrency
This algorithm limits the number of requests in progress at the same time:
options.AddConcurrencyLimiter("concurrency", options =>
{
options.PermitLimit = 10;
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 5;
});Like an elevator, when it is full nobody enters until someone leaves. It protects a resource, such as database connections or CPU, rather than limiting a rate.
Using Rate Limiting in a Controller
[EnableRateLimiting("fixed")] applies a named policy to a controller or an action, and [DisableRateLimiting] turns rate limiting off for it.
Note: The first version of this article said that the middleware applies the rules to every incoming request. That is wrong: a named policy only applies where you attach it. An endpoint without a policy is not limited, unless you set a
GlobalLimiter(see below).
[Route("api/[controller]")]
[ApiController]
public sealed class CustomersController(ICustomerService customerService) : ControllerBase
{
[EnableRateLimiting("fixed")]
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<IReadOnlyList<CustomerDto>>> GetCustomers(CancellationToken cancellationToken)
{
var dtos = await customerService.GetCustomersAsync(cancellationToken);
return Ok(dtos);
}
[DisableRateLimiting]
[HttpGet("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CustomerDto>> GetCustomerById(Guid id, CancellationToken cancellationToken)
{
// ...
}
}[ProducesResponseType] lists the 429 and 404 responses in the OpenAPI document. Let's send 16 requests at once to GET /api/customers. The first 10 go through, the next 5 wait in the queue for the next window (about 10 seconds), and the last one is rejected:
for i in $(seq 16); do (curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:5207/api/customers &); done200 0.385094s
200 0.412106s
200 0.399785s
200 0.397099s
200 0.406528s
200 0.558784s
200 0.502623s
200 0.516216s
200 0.511728s
200 0.522055s
429 0.519069s
200 10.160396s
200 10.234152s
200 10.175415s
200 10.170592s
200 10.174853sUsing Rate Limiting in a Minimal API
A Minimal API endpoint calls RequireRateLimiting() instead, on the endpoint or on a route group (DisableRateLimiting() exists too):
app.MapGet("/api/minimal/customers", (ICustomerService customerService, CancellationToken cancellationToken) =>
customerService.GetCustomersAsync(cancellationToken))
.RequireRateLimiting("per-ip")
.ProducesProblem(StatusCodes.Status429TooManyRequests);Limiting per client
The policies above are shared: with fixed, the 10 requests per window are for all clients together, so one noisy client can get everybody else rejected. To limit per client, AddPolicy takes a function that returns a RateLimitPartition for each request; each partition key gets its own limiter.
// One fixed window per client IP address: a noisy client cannot use up everybody else's quota.
options.AddPolicy("per-ip", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromSeconds(10)
}));The client IP address is the usual key for anonymous traffic, with three caveats:
- Behind a reverse proxy,
RemoteIpAddressis the proxy's address, so every client shares one partition. Configure the Forwarded Headers middleware with the proxies you trust, beforeUseRateLimiter(). Never trustX-Forwarded-Forfrom anyone. - Many users can share one IP address, and one attacker can use many. For authenticated requests, a user id (
httpContext.User.Identity?.Name) or an API key is a better key. - Limits are kept in memory per instance: with three replicas, a client gets three times the quota. A shared limit needs a distributed store, such as Redis.
Limiting every request: the Global Limiter
For a limit that applies to every request, whatever the endpoint, set GlobalLimiter. It runs before the endpoint's own policy, and a request must get through both:
// Applies to every request, on top of the named policy of the endpoint (if any).
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}));On a freshly started app, 120 requests to /openapi/v1.json, which has no policy of its own, give 100 successes and 20 rejections. [DisableRateLimiting] also turns off the global limiter:
== 120 x GET /openapi/v1.json (global limiter only)
100 200
20 429
== 120 x GET /api/customers/{id} (DisableRateLimiting)
120 200Custom Status Code and Response
By default, a rejected request gets 503 Service Unavailable. Set RejectionStatusCode to return 429 Too Many Requests, and use OnRejected to add a Retry-After header and a problem details body:
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, _) =>
{
var detail = "Too many requests. Please try again later.";
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
var seconds = (int)Math.Ceiling(retryAfter.TotalSeconds);
context.HttpContext.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
detail = $"Too many requests. Please try again after {seconds} second(s).";
}
await TypedResults.Problem(detail: detail, statusCode: StatusCodes.Status429TooManyRequests)
.ExecuteAsync(context.HttpContext);
};TypedResults.Problem writes an RFC 9457 body through the service that builder.Services.AddProblemDetails() registers, so the 429 looks like the API's other errors. Only the fixed window and token bucket limiters attach RetryAfter to a rejected lease; the sliding window and concurrency limiters do not, hence the fallback message.
Note: The first version of this article only set
OnRejected, so the client still got a 503: the middleware sets the status toRejectionStatusCodebefore it callsOnRejected.
Here is the 6th request within 10 seconds to /api/minimal/customers:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 10
{"title":"Too Many Requests","status":429,"detail":"Too many requests. Please try again after 10 second(s).","traceId":"00-f09de91dab66c1fec3911a9646dcb8e9-022fe0ae2d6b4c47-00"}Conclusion
The built-in middleware covers four algorithms: Fixed Window, Sliding Window, Token Bucket and Concurrency. Pick the one that matches what you protect, a rate or a resource, partition it per client, and add a global limiter as a safety net.
Source code of the article: GitHub source code
References
- Rate limiting middleware in ASP.NET Core — the official guide: algorithms, policies, partitions and middleware order.
- RateLimiterOptions —
RejectionStatusCode,OnRejectedandGlobalLimiter. - System.Threading.RateLimiting — the limiter primitives and their options.
- Configure ASP.NET Core to work with proxy servers and load balancers — the Forwarded Headers middleware.
- Handle errors in ASP.NET Core APIs —
AddProblemDetailsand the problem details service. - Generate OpenAPI documents — the built-in
Microsoft.AspNetCore.OpenApipackage. - Announcing Rate Limiting for .NET — the .NET team's introduction of the APIs.
- RFC 6585, section 4 — the 429 Too Many Requests status code.
- RFC 9110, section 10.2.3 — the
Retry-Afterheader. - RFC 9457 — Problem Details for HTTP APIs.
- OWASP API4:2023 Unrestricted Resource Consumption — the risk rate limiting mitigates.