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