Articles › ASP.NET Core

Rate Limiting in ASP.NET Core: Protect Your API from Abuse

Protect your API with ASP.NET Core rate limiting: fixed window, sliding window, token bucket and concurrency limiters, per-user limits and 429 responses.

An API without limits is an open invitation: a buggy client in a retry loop, a scraper, or an attacker brute-forcing passwords can exhaust your server and your database. Rate limiting caps how many requests a client can make in a period of time. Since .NET 7, ASP.NET Core ships a built-in rate limiting middleware — no third-party package required.

Why rate limit?

  • Protect login and OTP endpoints from brute force and SMS-cost abuse.
  • Keep the service available for everyone when one client misbehaves.
  • Control costs of expensive operations (reports, AI calls, file processing).
  • Enforce plans — e.g. free tier 100 requests/hour, paid tier 10,000.

The four algorithms

Limiter How it works Good for
Fixed window N requests per fixed interval (e.g. 10 per minute, reset on the minute) simple, general limits
Sliding window window split into segments; smooths the burst at window edges fairer general limits
Token bucket bucket refills at a steady rate; each request takes a token; allows short bursts APIs with bursty clients
Concurrency max N requests in progress at once expensive endpoints, uploads

Basic setup

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

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

    options.AddFixedWindowLimiter("fixed", o =>
    {
        o.PermitLimit = 100;
        o.Window = TimeSpan.FromMinutes(1);
        o.QueueLimit = 0;
    });
});

var app = builder.Build();

app.UseRateLimiter();   // after UseRouting (implicit) and before endpoints

app.MapGet("/api/products", GetProducts).RequireRateLimiting("fixed");

With controllers, use attributes:

[EnableRateLimiting("fixed")]
public class ProductsController : ControllerBase
{
    [HttpGet] public IActionResult Get() => Ok();

    [DisableRateLimiting]
    [HttpGet("health")] public IActionResult Health() => Ok();
}

Warning: A named policy like the one above is shared by all clients — 100 requests per minute in total, not per user. For real APIs you almost always want partitioned limits.

Partitioning: per IP or per user

A partitioned limiter gives each key (IP, user ID, API key) its own counter:

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = 429;

    options.AddPolicy("per-user", httpContext =>
    {
        var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);

        if (userId is not null)
            return RateLimitPartition.GetTokenBucketLimiter(userId, _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = 50,
                TokensPerPeriod = 10,
                ReplenishmentPeriod = TimeSpan.FromSeconds(10),
                QueueLimit = 0,
                AutoReplenishment = true,
            });

        var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 20,
            Window = TimeSpan.FromMinutes(1),
        });
    });
});

Authenticated users get a generous bucket; anonymous traffic is limited per IP.

A strict policy for login and OTP

options.AddPolicy("auth", httpContext =>
    RateLimitPartition.GetSlidingWindowLimiter(
        httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new SlidingWindowRateLimiterOptions
        {
            PermitLimit = 5,
            Window = TimeSpan.FromMinutes(5),
            SegmentsPerWindow = 5,
        }));
[EnableRateLimiting("auth")]
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request) { ... }

Five attempts per five minutes per IP makes brute force impractical. For OTP endpoints also limit per phone number (from the request body or a service), otherwise an attacker rotating IPs can still spam one victim with SMS.

A global limiter

GlobalLimiter applies to every request, in addition to endpoint policies — a good safety net:

options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
    RateLimitPartition.GetFixedWindowLimiter(
        ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 300, Window = TimeSpan.FromMinutes(1) }));

Concurrency limits for expensive work

options.AddConcurrencyLimiter("reports", o =>
{
    o.PermitLimit = 4;          // at most 4 reports generating at once
    o.QueueLimit = 10;          // up to 10 more wait in line
    o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});

Queueing turns spikes into short waits instead of errors — keep the queue small so requests don't time out.

A friendly 429 response

Tell clients when to retry with the Retry-After header and a clear body:

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

    context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
    await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
    {
        Status = 429,
        Title = "Too many requests",
        Detail = "You have sent too many requests. Please try again later.",
    }, ct);

    var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
    logger.LogWarning("Rate limit hit for {Ip} on {Path}",
        context.HttpContext.Connection.RemoteIpAddress, context.HttpContext.Request.Path);
};

On the client, handle 429 by waiting for Retry-After (with exponential backoff) instead of retrying immediately.

Behind a proxy: get the real IP

Behind Nginx, IIS ARR, Cloudflare or a load balancer, RemoteIpAddress is the proxy's address — so every visitor shares 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();

Warning: Only trust X-Forwarded-For from known proxies. Otherwise any client can fake the header and get a fresh limit on every request.

Multiple servers

The built-in limiters keep counters in memory per instance. With three servers behind a load balancer, a client effectively gets three times the limit. That's often acceptable; if you need exact global limits, use a distributed store (for example Redis-based limiters) or enforce limits at the gateway (Nginx limit_req, Cloudflare, Azure API Management).

Choosing numbers

  • Start from real traffic: look at the p99 requests per minute of normal users and set limits comfortably above that.
  • Use stricter limits on sensitive endpoints (login, OTP, password reset, contact forms).
  • Log rejections and review them — they show both attacks and limits that are too tight.
  • Return limit information in headers (X-RateLimit-Remaining is a common convention) if API consumers need it.

Summary

ASP.NET Core's built-in middleware covers fixed window, sliding window, token bucket and concurrency limits. Partition limits per user or IP, apply strict policies to authentication and OTP endpoints, add a global safety net, return a helpful 429 with Retry-After, and configure forwarded headers correctly behind proxies. A few lines of configuration protect both your server and your users.

FAQ

Which limiter should I choose?

Token bucket for general per-user API limits (it allows small bursts), sliding window for login/OTP, and concurrency for heavy endpoints.

Is rate limiting enough to stop DDoS attacks?

No. Application-level limits protect against abuse and brute force, but large DDoS attacks must be stopped earlier — at a CDN, firewall or provider level.

Does rate limiting work with SignalR?

It applies to the HTTP negotiate and connection requests, not to individual hub messages; limit hub method calls inside the hub or with a hub filter.