Imagine your API receives thousands of requests in a few seconds. Some requests come from real users, while others may come from a script sending requests repeatedly. Your server now has to handle more work, and normal users may start experiencing slow responses.
This is where rate limiting helps.
Rate limiting controls how many requests a client can make within a specific period. For example, you might allow 100 requests per minute from a client. If the client sends more requests than that, the API can reject the extra requests until the limit allows them again.
ASP.NET Core includes built-in rate limiting middleware, so you can add this protection without building your own request counter.
In this article, you will learn how to add rate limiting to an ASP.NET Core API, choose a suitable policy, return useful error responses, and avoid common mistakes.
What Is Rate Limiting?
Rate limiting puts a limit on how often a client can call an API.
For example, suppose you have an API that returns customer details. You want to allow up to 60 requests per minute from each client.
If a client sends 45 requests in one minute, the requests can continue normally. If the client sends 80 requests during the same period, the API may reject some of the extra requests, depending on the policy you configure.
Rate limiting is useful for several reasons:
- It helps protect APIs from excessive requests.
- It reduces unnecessary work for the server.
- It helps prevent accidental request loops from overwhelming an API.
- It can make API usage more predictable.
- It helps control access to expensive operations, such as report generation.
However, rate limiting is not a complete security solution. You should still use authentication, authorization, input validation, and other security controls.
How Rate Limiting Works in ASP.NET Core
ASP.NET Core provides rate limiting middleware through the Microsoft.AspNetCore.RateLimiting namespace.
You define a policy that controls how requests are counted and when additional requests are rejected. You then apply that policy to your API.
The basic setup has three steps:
- Register a rate limiting policy.
- Add the rate limiting middleware to the request pipeline.
- Apply the policy to the endpoints you want to protect.
Let's start with a simple example.
Step 1: Add a Rate Limiting Policy
Open your Program.cs file and register a fixed-window rate limiter.
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("ApiPolicy", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.RejectionStatusCode =
StatusCodes.Status429TooManyRequests;
});
var app = builder.Build();
app.MapControllers();
app.Run();
Let's understand the important settings.
PermitLimit = 100 allows up to 100 requests during each window.Window = TimeSpan.FromMinutes(1) sets the window to one minute.QueueLimit = 0 means extra requests are not placed in a waiting queue.RejectionStatusCode tells ASP.NET Core to return HTTP 429 when a request is rejected.
The policy is named ApiPolicy. You can use this name when applying the limiter to an endpoint.
Important: This example registers the policy, but it does not yet apply it to any endpoint. We will do that next.
Step 2: Apply Rate Limiting to an API
You can apply a named rate limiting policy to a controller or an individual endpoint.
For a controller-based API, use the [EnableRateLimiting] attribute.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("ApiPolicy")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetProducts()
{
return Ok(new[]
{
new { Id = 1, Name = "Laptop" },
new { Id = 2, Name = "Keyboard" },
new { Id = 3, Name = "Mouse" }
});
}
}
Now, requests to the products endpoint use the ApiPolicy rate limiter.
If a client exceeds the configured limit, the middleware rejects additional requests with HTTP 429 Too Many Requests.
You can also apply a policy to a specific endpoint instead of an entire controller:
app.MapGet("/api/reports", () =>
{
return Results.Ok("Report data");
})
.RequireRateLimiting("ApiPolicy");
This is useful when only certain endpoints need rate limiting.
For example, you may want a stricter limit on report generation than on a simple endpoint that returns a list of categories.
Step 3: Add Middleware in the Correct Order
Registering a rate limiting policy is not enough. You must also add the middleware to the request pipeline.
A typical setup looks like this:
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();
app.Run();
The exact order depends on how your application is configured. For endpoint-specific rate limiting, routing must run before the rate limiter. When a policy uses authenticated user information, authentication must run before rate limiting so the user identity is available.
Also, make sure the middleware is applied before the protected endpoints handle requests.
If you are using a minimal API or a different routing setup, follow the middleware requirements for that application structure.
Understand the Different Rate Limiting Algorithms
ASP.NET Core supports several rate limiting approaches. Choosing the right one depends on the type of API and the traffic you expect.
Fixed Window
A fixed-window limiter allows a set number of requests during a fixed period.
For example, an API might allow 100 requests per minute.
options.AddFixedWindowLimiter("FixedPolicy", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
This approach is simple and works well for many APIs.
However, requests can arrive in a burst around the boundary between two windows. A client might use nearly all its requests at the end of one window and then use another full allowance immediately after the next window begins.
Sliding Window
A sliding-window limiter tracks request usage across smaller time segments within a larger window. This helps spread the limit more evenly over time.
options.AddSlidingWindowLimiter("SlidingPolicy", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.SegmentsPerWindow = 6;
limiter.QueueLimit = 0;
});
Here, the one-minute window is divided into six segments.
Sliding windows can reduce the sharp bursts associated with fixed windows, although they require more tracking.
Token Bucket
A token-bucket limiter allows requests when tokens are available. Tokens are added to the bucket over time, up to a configured capacity.
options.AddTokenBucketLimiter("TokenPolicy", limiter =>
{
limiter.TokenLimit = 100;
limiter.TokensPerPeriod = 20;
limiter.ReplenishmentPeriod =
TimeSpan.FromSeconds(10);
limiter.AutoReplenishment = true;
limiter.QueueLimit = 0;
});
This configuration starts with a capacity of 100 tokens and adds 20 tokens every 10 seconds.
It allows short bursts while controlling the longer-term request rate.
Concurrency Limiter
A concurrency limiter controls how many requests can be running at the same time. It does not limit the total number of requests during a minute.
options.AddConcurrencyLimiter("ConcurrencyPolicy", limiter =>
{
limiter.PermitLimit = 20;
limiter.QueueLimit = 0;
});
This configuration allows up to 20 requests to hold permits at once.
It can be useful for expensive operations that consume significant memory, CPU, database connections, or other resources.
For example, you might use a concurrency limiter for a report-generation endpoint where several simultaneous requests could put too much pressure on the server.
Return a Useful Response When the Limit Is Reached
When a rate limiter rejects a request, the client should receive a clear response.
HTTP 429 is the standard status code for Too Many Requests. You can also configure an OnRejected callback to return a response body.
Add the callback to your rate limiter configuration:
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("ApiPolicy", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode =
StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.ContentType =
"application/json";
await context.HttpContext.Response.WriteAsJsonAsync(
new
{
message = "Too many requests. Please try again later."
},
cancellationToken);
};
});
The response tells the client why the request was rejected and encourages it to wait before trying again.
If your API provides a Retry-After header, clients can use it to decide when to retry. Only send a specific retry time when your application can determine a meaningful value.
Clients should also avoid retrying immediately in a tight loop. A short delay with increasing backoff is usually a better approach.
Rate Limit by User or Client
A single global limit may not be suitable for every API. For example, one busy customer should not necessarily consume the entire allowance intended for all customers.
ASP.NET Core supports partitioned rate limiting, where each partition has its own limiter.
A common approach is to partition by authenticated user ID.
using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("UserPolicy", httpContext =>
{
var userId = httpContext.User.Identity?.IsAuthenticated == true
? httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.Identity.Name
: null;
var partitionKey = userId ?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
});
});
});
This example uses an authenticated user's sub claim when available and falls back to the identity name. Your authentication system may use a different claim for the user ID, so adjust it to match your application.
Anonymous requests share the same anonymous partition in this example. That is simple, but it may not be appropriate for a public API with many anonymous clients.
Avoid using arbitrary values supplied by clients as trusted identities. If you partition by IP address, remember that proxy and load-balancer settings affect which address the application sees. Configure forwarded headers carefully and trust only known proxies.
For APIs that use API keys, a validated client identifier can be a better partition key than an untrusted request header.
Common Rate Limiting Mistakes
| Mistake | Why it causes problems | Better approach |
|---|
| Registering a policy but never applying it | The intended endpoints may not be limited | Apply the policy using an attribute or endpoint configuration |
| Choosing a very low limit | Normal users may receive unnecessary 429 responses | Set limits based on real traffic and testing |
| Using one shared partition for every user | One client can affect other clients | Use suitable per-user or per-client partitions |
| Trusting arbitrary client headers | Clients can change those values | Use validated identities and trusted proxy settings |
| Retrying immediately after HTTP 429 | Can create even more traffic | Respect retry guidance and use backoff |
| Assuming in-memory limits cover every server | Separate instances may enforce separate limits | Use a shared gateway or distributed approach when a global limit is required |
Best Practices for Production APIs
Before deploying rate limiting, keep these points in mind:
- Choose the right algorithm. Use fixed windows for simple limits, sliding windows for smoother usage, token buckets for controlled bursts, and concurrency limits for expensive operations.
- Set limits based on actual usage. Review traffic patterns, service capacity, and the needs of legitimate clients.
- Protect expensive endpoints first. Login attempts, report generation, search, and other resource-heavy operations may need stricter policies.
- Return HTTP 429 clearly. Give clients a useful error message and, when possible, reliable retry guidance.
- Monitor rejected requests. Track rejection counts and response times to see whether the limits are too strict or traffic is unusually high.
- Test under realistic load. Verify normal traffic, bursts, concurrent requests, and authenticated and anonymous clients.
- Plan for multiple instances. Built-in ASP.NET Core rate limiting is generally enforced within each application process. If you need one shared limit across several instances, use a suitable gateway or distributed rate-limiting design.
- Keep other security controls. Rate limiting does not replace authentication, authorization, input validation, or protection against other attacks.
Conclusion
Rate limiting is a practical way to protect ASP.NET Core APIs from excessive requests and help keep services responsive.
ASP.NET Core provides built-in middleware that lets you configure fixed-window, sliding-window, token-bucket, and concurrency limits. You can apply these policies to individual endpoints or controllers and return HTTP 429 when a client exceeds its allowance.
Start with a simple policy, choose limits that match your application's needs, and monitor how often requests are rejected. For applications running across multiple servers, also decide whether the limit must be shared across all instances.
A good rate-limiting setup should protect your API without making normal users struggle to use it.
Jasen FiciPosted Oct 26, 2026, 12:23 PM
We picked this up for DotNetNews. Here's the issue: https://dotnetnews.beehiiv.com/p/new-post-664f