Skip to main content

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

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 /login makes 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 RejectionStatusCode to 429 — the default 503 misleads clients and monitoring into thinking the server is down.
  • Always send Retry-After so 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-After header, 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.

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

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...

Send an Email via SMTP with MailKit Using .NET 6

How to Send an Email in .NET Core This tutorial show you how to send an email in .NET 6.0 using the MailKit email client library. Install MailKit via NuGet Visual Studio Package Manager Console: Install-Package MailKit How to Send an HTML Email in .NET 6.0 This code sends a simple HTML email using the Gmail SMTP service. There are instructions further below on how to use a few other popular SMTP providers - Gmail, Hotmail, Office 365. // create email message var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse("from_address@example.com")); email.To.Add(MailboxAddress.Parse("to_address@example.com")); email.Subject = "Email Subject"; email.Body = new TextPart(TextFormat.Html) { Text = "<h1>Test HTML Message Body</h1>" }; // send email using var smtp = new SmtpClient(); smtp.Connect("smtp.gmail.com", 587, SecureSocketOptions.StartTls); smtp.Authenticate("[Username]", "[Password]"); smtp.Se...