Skip to main content

ASP.NET Core Rate Limiting: Protect Your API (2026 Guide)

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 /login to 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 Requests is 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 RejectionStatusCode explicitly — clients and CDNs treat 429 and 503 very differently.
  • Middleware order matters. UseRateLimiter() must be called after UseRouting() (implicit in minimal APIs) so it can see endpoint metadata, and after UseAuthentication() 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 = 0 on 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 k6 or bombardier will 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 QueueLimit at zero for public traffic.
  • Return Retry-After and a Problem Details body from OnRejected so 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...