Updated on

The rate limiting middleware has shipped since ASP.NET Core 7.0, and the code here runs on .NET 10. We configure it with builder.Services.AddRateLimiter(), put it in the pipeline with app.UseRateLimiter(), and attach a policy to an endpoint with the [EnableRateLimiting] attribute. There is no package to install.

To download the source code for this article, you can visit our GitHub repository.

Four algorithms come in the box: fixed window, sliding window, token bucket, and concurrency. The first three cap requests over time. The fourth caps requests in flight.

One thing catches everyone. A request that exceeds the limit comes back as 503 Service Unavailable, not 429 Too Many Requests, until we say otherwise.

What Is Rate Limiting in ASP.NET Core?

Rate limiting caps how many requests a caller may make in a given period. ASP.NET Core ships it as middleware in the Microsoft.AspNetCore.RateLimiting namespace, part of the shared framework since ASP.NET Core 7.0, so there is no package to install.

Three pieces make it work. A policy describes the limit. app.UseRateLimiter() puts the middleware in the pipeline. An attribute or an endpoint call attaches the policy to the endpoints it guards.

The limit itself comes from one of four algorithms: fixed window, sliding window, token bucket, and concurrency. The first three count requests over time. The fourth counts requests in flight.

Rate limiting is not authentication and it is not authorization. It never asks who the caller is, it counts what the caller does. Partitioning is the bridge between the two: it splits the count per token, per API key, or per IP address, so one tenant’s traffic cannot spend another tenant’s budget, and a spam script cannot spend everyone’s.

Partitioning needs a key that identifies the caller, and API key authentication, which gives us a natural partition key, is one way to get one. Cloud providers use the same idea to stop a single tenant monopolising shared resources, and social platforms use it to control API spam.

Why Is Rate Limiting Built Into ASP.NET Core Now?

Before ASP.NET Core 7.0, rate limiting meant a third-party package such as AspNetCoreRateLimit or a hand-written middleware. Both still work, and neither is worth choosing on a new project now that the framework does the same job with AddRateLimiter().

If we are maintaining one of those older setups, rate limiting with custom middleware and the AspNetCoreRateLimit package covers it.

How Do We Register the Rate Limiting Middleware?

Registering takes two calls. builder.Services.AddRateLimiter() configures the policies, and app.UseRateLimiter() puts the middleware into the request pipeline.

Order in the pipeline matters, and it matters for one specific reason. Endpoint-specific limits need UseRateLimiter() to run after UseRouting(), because the middleware reads the policy off the matched endpoint’s metadata, and before routing there is no matched endpoint to read. A global limiter needs no endpoint, so it works anywhere in the pipeline.

Past that constraint, put it early. Every middleware that runs before the limiter does work for a request that is about to be thrown away.

AddRateLimiter() may be called more than once. Each call configures the same RateLimiterOptions instance, so policies registered in separate calls accumulate instead of replacing one another. That also means a setting applied in one call, such as the rejection status code, quietly applies to every policy in the application.

app.UseRateLimiter();

Microsoft’s rate limiting middleware documentation states the constraint outright: “UseRateLimiter must be called after UseRouting when rate limiting endpoint specific APIs are used.”

Getting that order wrong fails silently. With UseRateLimiter() placed before UseRouting(), an endpoint policy of two permits let five out of five requests through: no exception, no warning, the limit simply never applied. A rate limiter that looks like working code and enforces nothing is worse than no rate limiter at all, so this is the one line of pipeline order worth checking by hand.

The same wiring shape shows up elsewhere in the framework, for example in the request timeouts middleware, which is wired the same way.

Which Built-In Rate Limiter Should We Use?

Four limiters ship in the box, and the choice between them comes down to one question: what are we protecting?

Fixed window is the default answer. It allows PermitLimit requests per Window and resets the count at the boundary. It is the easiest to explain to whoever has to sign off the number, and it permits a burst across the boundary, because the last requests of one window and the first of the next arrive back to back.

Sliding window buys that boundary away. It splits the window into segments and expires them one at a time, so the count reflects the trailing window rather than the current one.

Token bucket smooths rather than counts. A request spends a token, tokens return at a fixed rate, and a caller who has been quiet may burst up to the bucket’s size.

Concurrency is the odd one out. It caps requests in flight, and a permit comes back when the request finishes rather than when a clock ticks.

LimiterExtension methodOptions typeWhat it countsAllows a burst?Reach for it when
Fixed windowAddFixedWindowLimiterFixedWindowRateLimiterOptionsPermitLimit requests per Window, reset at the boundaryYes, across the boundaryThe traffic is steady and the limit has to be easy to explain
Sliding windowAddSlidingWindowLimiterSlidingWindowRateLimiterOptionsPermitLimit requests across the trailing Window, expired one segment at a time (SegmentsPerWindow)No boundary burstThe boundary burst matters and the extra bookkeeping is affordable
Token bucketAddTokenBucketLimiterTokenBucketRateLimiterOptionsOne token per request; TokensPerPeriod returned every ReplenishmentPeriod, capped at TokenLimitYes, up to TokenLimit after a quiet spellBursty clients should be allowed to burst, within a long-run average
ConcurrencyAddConcurrencyLimiterConcurrencyLimiterOptionsPermitLimit requests in flight at once; a permit returns when the request finishesNot applicable, there is no clockThe scarce resource is capacity, not rate: uploads, reports, an upstream connection pool

QueueLimit and QueueProcessingOrder sit on every one of the four options types, so requests over the limit can wait rather than fail.

Fixed Window Limiter

Here we first add the rate limiter service. Then, we set up a fixed window limiter where we name the policy which we will later use to configure which endpoints it will affect.

The AddFixedWindowLimiter method enforces the rate limit within a specified duration, known as the time window. During this window, the system allows up to a set number of requests. If the limit is set to 100 requests per minute, the system will either queue or reject any additional requests, depending on the configuration. The system resets the allowed number of requests as each window expires. This type of limiter is useful for managing scenarios with relatively uniform traffic:

builder.Services.AddRateLimiter(options => options
    .AddFixedWindowLimiter(policyName: "fixed", limiterOptions =>
    {
        limiterOptions.PermitLimit = 100;
        limiterOptions.Window = TimeSpan.FromMinutes(1);
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit = 5;
    }));

Here the limiter permits up to 100 requests per minute and allows five requests to queue once the rate limit is reached. We also specify that we process the oldest requests first. Any additional request after 105 requests within one minute is rejected, with the status code the section on rejected requests covers below.

Sliding Window Limiter

The sliding window algorithm provides a refinement to the fixed window limiter by dividing the time window into smaller segments. This, in turn, offers smoother traffic handling and a more evenly distributed load. This is especially beneficial in systems where traffic intensity fluctuates rapidly. This mechanism ensures a continuous evaluation of request counts, offering a more dynamic and responsive rate limiting compared to the fixed window approach:

builder.Services.AddRateLimiter(options => options
    .AddSlidingWindowLimiter(policyName: "sliding", limiterOptions =>
    {
        limiterOptions.PermitLimit = 100;
        limiterOptions.Window = TimeSpan.FromMinutes(1);
        limiterOptions.SegmentsPerWindow = 10;
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit = 5;
    }));

Here we allow 100 requests per minute, as we did for the fixed window, plus one extra property: SegmentsPerWindow splits the one-minute Window into 10 segments of six seconds each. Segments control how permits come back, not how they are spent. The limiter still admits up to PermitLimit requests across the trailing window, so all 100 may arrive in the first second; each permit then returns six seconds after the segment it was taken in leaves the window. We again allow up to 5 requests to queue and process the oldest first.

Token Bucket Limiter

The token bucket limiter manages request rates by maintaining a balance of tokens and adding them to the bucket at a fixed rate. When a request comes in, it consumes a token. As long as the bucket has enough tokens, we allow the request. If there are no free tokens, we reject or queue the request, depending on our configuration. This method allows for handling bursts of requests up to the bucket’s limit, without breaching the overall rate limit. Tokens are added to the bucket until the TokenLimit is reached:

builder.Services.AddRateLimiter(options => options
    .AddTokenBucketLimiter(policyName: "token", limiterOptions =>
    {
        limiterOptions.TokenLimit = 100;
        limiterOptions.ReplenishmentPeriod = TimeSpan.FromMinutes(1);
        limiterOptions.TokensPerPeriod = 10;
        limiterOptions.AutoReplenishment = true;
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit = 5;
    }));

Here we configure a token limit of 100, which is the largest number of tokens the bucket can hold, and a replenishment of 10 tokens a minute. Without a partitioner, this is one bucket shared by every caller, not a bucket each. A bucket that has been idle long enough to fill admits a burst of TokenLimit requests, 100 here, not TokensPerPeriod, and once it is empty the long-run rate settles at 10 requests a minute plus the 5 we allow to queue. The section on per-user limits further down shows the partitioned version, which is the one that gives each client a bucket of its own.

Concurrency Limiter

Unlike token bucket or window-based limiters that regulate the rate of incoming requests over time, a concurrency limiter focuses on the number of simultaneous requests taking place. This approach is particularly useful in scenarios where we want to prevent system overload due to too many processes running at the same time, rather than managing the flow rate of requests entering the system. This method works well in environments with limited capacity for processing concurrent operations:

builder.Services.AddRateLimiter(options => options
    .AddConcurrencyLimiter(policyName: "concurrency", limiterOptions =>
    {
        limiterOptions.PermitLimit = 10;
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit = 5;
    }));

Here, we are only defining the PermitLimit which denotes how many concurrent requests we allow at one time. A permit comes back the moment a request finishes rather than when a clock ticks, which is why this limiter has no Window. Once again we allow up to 5 additional requests to queue, and past that limit, 15 requests in flight in this example, the middleware rejects the request.

How Do We Apply a Policy With [EnableRateLimiting]?

A policy does nothing until something attaches it to an endpoint. There are four ways to do that, and an application can use all four at once.

[EnableRateLimiting("policy")] on a controller applies that policy to every action on it. The same attribute on a single action overrides the controller’s choice for that action alone.

[DisableRateLimiting] switches rate limiting off for whatever it decorates, and it wins outright. The middleware checks for it on the matched endpoint and returns before it consults any limiter, the global one included.

RequireRateLimiting("policy") does the attribute’s job for endpoints declared in Program.cs. We chain it onto MapGet(), onto MapControllers(), or onto a whole route group.

GlobalLimiter needs no attribute anywhere. It applies to every request in the application, and it runs before the endpoint-specific limiter rather than instead of it, so a request has to satisfy both.

HowWhere it goesWhat it coversNotes
[EnableRateLimiting("policy")]On a controller classEvery action on that controllerAn action's own attribute overrides it
[EnableRateLimiting("policy")]On an action methodThat action onlyWins over the controller's attribute
[DisableRateLimiting]On a controller or an actionTurns rate limiting off entirely for itWins over everything, including GlobalLimiter
.RequireRateLimiting("policy")Chained onto MapGet(), MapControllers(), MapDefaultControllerRoute() or a route group in Program.csThe endpoints that call producesThe Minimal API and route-group equivalent of the attribute
RateLimiterOptions.GlobalLimiterSet inside AddRateLimiter()Every request in the applicationRuns before the endpoint limiter, not instead of it

If we want to enable rate limiting for all of the endpoints, we can do it in one go by adding a one-liner in the Program.cs file. It should be added after app.UseRouting(), but before app.Run():

app.MapDefaultControllerRoute().RequireRateLimiting(nameOfPolicy);

We are chaining RequireRateLimiting(nameOfPolicy) method with the MapDefaultControllerRoute() method since it is used for setting up routing to our controllers automatically. By chaining them together, we specify that the default controller routes should also enforce rate limiting according to our defined policies. Note that we must create the policy and register the rate-limiting middleware.

For a more granular approach, we can disable or enable rate limiting for specific controllers or actions by applying different attributes to a controller. For disabling we use the [DisableRateLimiting] attribute on the top of the controller definition or action method. On the contrary, to enable it we use [EnableRateLimiting]. We can also create a custom attribute to deal with rate limiting as we see fit:

[EnableRateLimiting("fixed")]
[ApiController]
[Route("customer")]
public class CustomerController : ControllerBase
{
    [HttpGet("Details")]
    [EnableRateLimiting("sliding")]
    public ActionResult Details() => Ok();

    [HttpGet("SpecialOffer")]
    [DisableRateLimiting]
    public ActionResult SpecialOffer() => Ok();
}

Here we define the entire controller to have a fixed rate-limiting policy indicated by the [EnableRateLimiting("fixed")] attribute. For the Details() action method we define a different policy, the sliding one, overriding the controller’s default. And for the SpecialOffer() action method, we turn off the rate limiting altogether.

What Status Code Does a Rate-Limited Request Return?

A rejected request comes back as 503 Service Unavailable, not 429 Too Many Requests. RateLimiterOptions.RejectionStatusCode defaults to StatusCodes.Status503ServiceUnavailable, and nothing in the four limiter configurations changes it.

Setting it to StatusCodes.Status429TooManyRequests is one line inside AddRateLimiter(), and because those options accumulate, one line covers every policy in the application.

The middleware writes no Retry-After header either. Every well-behaved client and every HTTP library that backs off on 429 reads that header, so a limiter without one teaches callers to retry immediately and makes the problem worse.

OnRejected fixes both. It receives an OnRejectedContext carrying the failed lease, and the lease carries the answer: lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter) gives us how long to wait. The fixed window and the token bucket populate it; the sliding window and the concurrency limiter do not.

A status code set inside OnRejected wins, because the middleware writes the default first and then calls the callback.

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    limiterOptions.OnRejected = (context, cancellationToken) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString(NumberFormatInfo.InvariantInfo);
        }

        return ValueTask.CompletedTask;
    };
});

That is the whole fix: every policy in the application now answers with 429, and every rejection from a limiter that knows when the next permit arrives carries a Retry-After header. On the client side, retrying a failed request with Polly, which is what a well-behaved client does with our Retry-After, is the other half of the same contract.

How Do We Rate Limit a Minimal API Endpoint?

In .NET, Minimal APIs have become increasingly popular for their simplicity and efficiency in setting up lightweight web services. They help simplify the process of web service creation by reducing the amount of code required to set up basic routing and request handling. We ditch the conventional patterns of controllers and views, and instead, directly map endpoints to lambda expressions or methods in Program.cs file. To learn more about Minimal API, please check out our article.

So let’s see how to integrate rate limiting in Minimal APIs. The configuration part is the same as for a normal API, as mentioned above. But when defining endpoints we have some differences:

var app = builder.Build();

app.MapGet("/myendpoint", () => "This endpoint is rate-limited.")
   .RequireRateLimiting("concurrency");

app.Run();

Here, we protect the /myendpoint route with the previously defined “concurrency” rate-limiting policy. This is accomplished by calling the RequireRateLimiting() extension method.

How Do We Rate Limit per User or per Token?

Integrating rate-limiting features with authentication and authorization enables us to control the rate of requests based on the identity of the requester. By following this approach, we can help to provide different service levels to different users or protect resources from abuse:

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.AddPolicy(policyName: "jwt", partitioner: httpContext =>
    {
        var accessToken = httpContext.GetTokenAsync("access_token").Result;

        return !string.IsNullOrEmpty(accessToken)
            ? RateLimitPartition.GetFixedWindowLimiter(accessToken, options =>
                new FixedWindowRateLimiterOptions
                {
                    QueueLimit = 5,
                    PermitLimit = 100,
                    Window = TimeSpan.FromMinutes(1),
                })
            : RateLimitPartition.GetFixedWindowLimiter("Anon", options =>
                new FixedWindowRateLimiterOptions
                {
                    QueueLimit = 5,
                    PermitLimit = 10,
                    Window = TimeSpan.FromMinutes(1),
                });
    });
});

Here we set up rate limiting with different policies for authenticated and anonymous users. We allow authenticated users to fire 100 requests per minute, and unauthorized users, only 10. This is the partitioned form the token bucket section pointed at: the access token is the partition key, so each caller gets a limiter of their own instead of sharing one.

Every count here lives in the memory of one process. Two instances behind a load balancer each enforce the full limit, so a caller who is routed across both gets twice the budget we configured, and a shared store is the only thing that closes that gap.

How Do We Chain Two Rate Limiters Together?

We can layer (or chain) multiple rate-limiting strategies together for more complex scenarios using the PartitionedRateLimiter class:

builder.Services.AddRateLimiter(limiterOptions =>
{
    limiterOptions.GlobalLimiter = PartitionedRateLimiter.CreateChained(
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var userAgent = httpContext.Request.Headers.UserAgent.ToString();
            return RateLimitPartition.GetFixedWindowLimiter(
                userAgent, _ => new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 4,
                    Window = TimeSpan.FromSeconds(2)
                });
        }),
        PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
        {
            var clientIP = httpContext.Connection.RemoteIpAddress!.ToString();
            return RateLimitPartition.GetFixedWindowLimiter(
                clientIP, _ => new FixedWindowRateLimiterOptions
                {
                    AutoReplenishment = true,
                    PermitLimit = 20,
                    Window = TimeSpan.FromSeconds(30)
                });
        }));
});

Here, we use the CreateChained() method to chain two limiters together. This causes the incoming requests to pass through each limiter in sequence. Chaining these two limiters ensures that a request must pass the conditions of the first limiter and then the second one before processing. Incoming requests first try to pass the userAgent limiter, which allows only 4 requests within 2 seconds from the same device or browser. If the request passes, it proceeds to the clientIP which allows 20 requests within 30 seconds from the same internet connection. Our app processes only requests that successfully pass both the limiters.

The chained limiter ships in the sample as code to read, not as a registered limiter. Four requests per two seconds per user agent would rate limit the test host itself, so the sample leaves the registration commented out and its tests exercise the other five policies instead.

This setup is especially useful in scenarios requiring a combination of different dimensions of rate limiting, such as per-user-agent and per-IP. It safeguards our app from excessive requests from multiple sources, which is a common shape for denial-of-service attacks. We can customize the limits per each limiter and even add more for finer control. Remember that each gate acts independently.

The second partition keys on the caller’s address, so it is worth knowing how to get the caller’s IP address in ASP.NET Core before relying on it behind a proxy or a load balancer. When a single address turns out to be the whole problem, blocking an IP address outright is the blunter instrument.

Conclusion

Rate limiting lets us control how often callers reach our services. It protects our APIs from attacks, manages user quotas, and prevents misuse. We can use the built-in limiters as they come and tweak them as we see fit. The two things worth getting right before anything ships: the status code a rejected request returns, and whether the limiter partitions per caller or counts everyone into one bucket.

Tested with .NET 10.0.10.