Back to all posts
·6 min read

Rate limiting in ASP.NET Core: protecting your API from abuse

On the day you launch an API, traffic is well-behaved. A few weeks later someone's buggy loop sends hundreds of requests per second, someone else starts guessing passwords, and a partner's script decides to pull all your data at midnight. Rate limiting is the cheapest way to protect the rest of the system in these situations.

Since .NET 7, ASP.NET Core ships a built-in middleware for this, with no extra package required. In this post I cover the algorithms, per-user partitioning, how to respond to a rejected request, and where the built-in solution falls short.

What are we limiting, and why?

Rate limiting can serve three different goals, and which one you target determines the settings:

  • Protection: Stop a faulty or malicious client from consuming all resources.
  • Fairness: Stop one noisy customer from degrading response times for everyone else.
  • Cost: Keep expensive endpoints (reporting, AI calls, email sending) under control.

A limit does not close a security hole; it only raises the cost of an attack. It does not replace authentication and authorization, it complements them.

Four built-in algorithms

The middleware is built on System.Threading.RateLimiting and offers four limiters:

  • Fixed window: A fixed number of requests allowed per window (say, 100 per minute). It is the simplest, but bursts can occur at window boundaries. A client that sends 100 requests in the last second of one window and 100 in the first second of the next has made 200 requests in a very short time.
  • Sliding window: Splits the window into segments and slides it forward. It reduces the boundary burst problem at the cost of slightly more memory.
  • Token bucket: The bucket refills at a fixed rate and each request spends a token. It allows short bursts while limiting the long-term average rate. It is a good default for most general-purpose APIs.
  • Concurrency: Limits how many requests are processed at the same time. It restricts concurrency, not rate, which suits slow and expensive endpoints.

My rule of thumb: token bucket for the general API, fixed or sliding window for hard limits such as password attempts, concurrency for heavy jobs.

Basic setup

Policies are defined with AddRateLimiter and enabled with UseRateLimiter:

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

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

    options.AddTokenBucketLimiter("general", o =>
    {
        o.TokenLimit = 50;
        o.TokensPerPeriod = 10;
        o.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
        o.QueueLimit = 0;
        o.AutoReplenishment = true;
    });

    options.AddConcurrencyLimiter("heavy-job", o =>
    {
        o.PermitLimit = 4;
        o.QueueLimit = 10;
    });
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/products", () => Results.Ok())
   .RequireRateLimiting("general");

app.MapPost("/reports", () => Results.Accepted())
   .RequireRateLimiting("heavy-job");

app.Run();

QueueLimit = 0 means a request is rejected immediately once the limit is exceeded. A larger queue makes requests wait, which is gentler on the user, but waiting requests also hold resources. Zero is a sensible start for general APIs, and a small queue for endpoints where short bursts are expected.

Don't put everyone in the same bucket: partitioning

The policies above keep a single counter for all clients. If one user exhausts the limit, everyone gets a 429. To avoid that, partition requests by a key:

options.AddPolicy("per-user", httpContext =>
{
    var key =
        httpContext.User.Identity?.IsAuthenticated == true
            ? $"user:{httpContext.User.FindFirst("sub")?.Value}"
            : $"ip:{httpContext.Connection.RemoteIpAddress}";

    return RateLimitPartition.GetTokenBucketLimiter(key, _ =>
        new TokenBucketRateLimiterOptions
        {
            TokenLimit = 30,
            TokensPerPeriod = 5,
            ReplenishmentPeriod = TimeSpan.FromSeconds(1),
            QueueLimit = 0
        });
});

The choice of key matters. For authenticated users, the user or tenant ID is the fairest key. For anonymous requests we have to fall back to the IP address, and many users behind a shared network (an office, a mobile carrier) can share one IP. That is why the anonymous limit may need to be more generous than the one for signed-in users.

If the key comes from RemoteIpAddress behind a proxy or load balancer, then without a correctly configured ForwardedHeaders middleware everyone looks like the proxy's IP and all traffic lands in one bucket. Conversely, if you trust the X-Forwarded-For header unconditionally, an attacker can send a different value on every request and bypass the limit. Only trust the header from proxies you know.

Responding well to a rejected request

The default 429 response has no body. Telling the client when to retry improves the user experience and reduces pointless retry load:

options.OnRejected = async (context, ct) =>
{
    if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var wait))
    {
        context.HttpContext.Response.Headers.RetryAfter =
            ((int)wait.TotalSeconds).ToString();
    }

    context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
    await context.HttpContext.Response.WriteAsJsonAsync(new
    {
        error = "too_many_requests",
        message = "You have reached the request limit. Please try again shortly."
    }, ct);
};

The Retry-After header is a standard contract, and well-written clients honor it. Not every limiter produces this metadata; the concurrency limiter, for example, cannot know the wait time, so we write the header conditionally.

Do your part on the client side too: your own SDK or front end should not retry immediately after a 429. It should wait for the Retry-After duration and apply exponential backoff with jitter. Otherwise the limit feeds a retry storm.

Separate, strict limits for critical endpoints

Not all endpoints are equal. Login, password reset, and sign-up endpoints need much tighter limits against brute-force attempts:

options.AddPolicy("login", httpContext =>
    RateLimitPartition.GetFixedWindowLimiter(
        $"login:{httpContext.Connection.RemoteIpAddress}",
        _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 5,
            Window = TimeSpan.FromMinutes(1),
            QueueLimit = 0
        }));

Looking only at the IP is not enough here; you also need a counter per target account, because an attacker can use thousands of IPs. Account-based lockout and alerting logic is usually built separately in the application layer.

Limits of the built-in solution

The built-in limiters run in process memory, which has a few important consequences:

  • Multi-instance deployments: If the API has three replicas, each keeps its own counter, so the effective limit is multiplied by the replica count. If the load balancer distributes requests evenly, this is often an acceptable approximation; if you need an exact limit, you need a shared store such as Redis or limiting at the API gateway level.
  • Restarts: Counters live in memory, so they reset on deployment.
  • Middleware order: The earlier you limit, the fewer resources are spent on bad requests. But if you use a per-user key, UseRateLimiter must come after the authentication middleware so that HttpContext.User is populated.

A good division of labor is to keep a coarse first line of defense at the reverse proxy or gateway, and the finer, business-aware limits in the application.

Testing and observing

Once you set a limit, verify its behavior. In an integration test, send requests just above the limit and check that the first rejection comes exactly where expected, that the Retry-After header is present, and that different users do not affect each other.

In production, track the number of 429s as a metric. A sudden spike points either to an attack or to a limit you set too tight. If real users see 429s, the limit is wrong; read it from usage data first, then adjust the value.

Closing

Rate limiting is not a magic shield; it is part of capacity planning. First decide which resource you want to protect, choose the algorithm accordingly, partition per user, communicate rejection with a clear response, and knowingly accept the approximation a multi-instance setup brings. The built-in middleware solves most of this in a few lines; the rest is choosing the right key and measuring behavior.