Rate limiting sounds simple. Limit requests per time window. But the details matter: which algorithm, what partition key, how to handle proxies, what to exempt.
Common questions this answers
- Which rate limiting algorithm should I use?
- How do I rate limit by client IP behind a reverse proxy?
- How do I exempt health checks and feeds from rate limiting?
- What happens when the client IP cannot be determined?
- How do I customize 429 responses?
Definition (what this means in practice)
Rate limiting restricts how many requests a client can make in a time period. The ASP.NET Core rate limiting middleware tracks requests per partition (IP, user, API key) and rejects excess requests with 429 Too Many Requests.
In practice, rate limiting protects against abuse, ensures fair resource distribution, and provides a first line of defense against simple attacks. It does not replace DDoS protection or web application firewalls.
Terms used
- Partition key: the identifier that groups requests (IP address, user ID, API key).
- Fixed window: requests counted in fixed time intervals that reset completely.
- Sliding window: requests counted over a moving time window with segments.
- Token bucket: tokens replenish over time; each request consumes a token.
- Concurrency limiter: limits simultaneous requests, not requests per time.
- 429 Too Many Requests: HTTP status code for rate-limited requests.
Reader contract
This article is for:
- Engineers adding rate limiting to ASP.NET Core applications.
- Teams choosing between rate limiting algorithms.
You will leave with:
- An algorithm selection decision table.
- Partition key patterns for IP, user, and composite keys.
- Exemption and edge case handling patterns.
This is not for:
- API gateway rate limiting (Kong, Azure API Management).
- DDoS protection strategies.
- Third-party rate limiting services.
Quick start (10 minutes)
If you need basic per-IP rate limiting:
Verified on: ASP.NET Core (.NET 10).
Note: UseRateLimiter requires AddRateLimiter. If the services aren't registered at startup, ASP.NET Core throws an error.
// Program.cs
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("per-ip", limiterOptions =>
{
limiterOptions.PermitLimit = 60;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRouting();
app.UseRateLimiter(); // After UseRouting
app.MapControllers().RequireRateLimiting("per-ip");
app.Run();
Rate limiting algorithms
ASP.NET Core provides four algorithms. Each has different characteristics.
Fixed window
Counts requests in fixed time intervals. When the window ends, the count resets to zero.
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
Behavior: A client can make 100 requests, then must wait for the window to reset.
Edge case: Bursts at window boundaries. A client could make 100 requests at 0:59, then 100 more at 1:00 when the window resets.
Sliding window
Divides the window into segments. As time passes, old segments expire and their capacity becomes available.
options.AddSlidingWindowLimiter("sliding", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 6; // 10-second segments
limiterOptions.QueueLimit = 0;
});
Behavior: Smoother rate limiting. Capacity recycles as segments expire rather than all at once.
Trade-off: More memory per partition (tracks per-segment counts).
Token bucket
Tokens accumulate over time up to a maximum. Each request consumes one token.
options.AddTokenBucketLimiter("token", limiterOptions =>
{
limiterOptions.TokenLimit = 100;
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
limiterOptions.TokensPerPeriod = 10;
limiterOptions.QueueLimit = 0;
});
Behavior: Allows bursts up to the token limit, then sustained rate based on replenishment.
Use case: APIs where occasional bursts are acceptable but sustained high rates are not.
Concurrency limiter
Limits simultaneous in-flight requests, not requests per time.
options.AddConcurrencyLimiter("concurrent", limiterOptions =>
{
limiterOptions.PermitLimit = 10;
limiterOptions.QueueLimit = 5;
});
Behavior: At most 10 requests processing at once. Additional requests queue or reject.
Use case: Protecting resources that degrade under concurrent load (database connections, external APIs).
Algorithm selection
| Scenario | Recommended algorithm | Rationale |
|---|---|---|
| Simple API protection | Fixed window | Easy to understand and configure |
| Smooth rate enforcement | Sliding window | Prevents window-boundary bursts |
| Allow occasional bursts | Token bucket | Accumulates capacity for burst handling |
| Protect concurrent resources | Concurrency | Limits simultaneous, not total |
| Combine burst + sustained | Chained limiters | Token bucket + fixed window together |
Partition key patterns
The partition key determines what is rate limited independently.
Per-IP address
Most common pattern. Each IP gets its own limit.
options.AddPolicy("per-ip", httpContext =>
{
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: clientIp,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1)
});
});
Per-user (authenticated)
Rate limit by authenticated user identity.
options.AddPolicy("per-user", httpContext =>
{
var userId = httpContext.User.Identity?.Name ?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: userId,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
});
});
Composite key (IP + resource)
Limit actions on specific resources per IP.
options.AddPolicy("per-ip-resource", httpContext =>
{
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var resourceId = httpContext.Request.RouteValues["id"]?.ToString() ?? "none";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"{clientIp}:{resourceId}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1)
});
});
Tiered by API key
Different limits for different API key tiers.
options.AddPolicy("tiered", httpContext =>
{
var apiKey = httpContext.Request.Headers["X-API-Key"].ToString();
var (limit, window) = apiKey switch
{
"premium-key" => (1000, TimeSpan.FromMinutes(1)),
"standard-key" => (100, TimeSpan.FromMinutes(1)),
_ => (10, TimeSpan.FromMinutes(1))
};
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: apiKey,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = limit,
Window = window
});
});
Handling missing client IP
Behind reverse proxies, HttpContext.Connection.RemoteIpAddress is populated by Forwarded Headers Middleware (typically from X-Forwarded-For). If ForwardedHeaders isn't enabled or isn't restricted to trusted proxies/networks, the client IP may be null, wrong, or spoofed.
Do not use a permissive fallback that bypasses rate limiting:
// BAD: Allows bypass if IP is missing
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// All unknown clients share one partition - easy to exhaust
Do use a strict fallback:
options.AddPolicy("per-ip", httpContext =>
{
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString();
if (string.IsNullOrEmpty(clientIp))
{
// Strict fallback: very tight limit for unidentified clients
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: "missing-ip",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 1,
Window = TimeSpan.FromMinutes(1)
});
}
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: clientIp,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1)
});
});
Exemption patterns
Some endpoints should not be rate limited.
Exempt specific endpoints
Use DisableRateLimiting attribute:
[DisableRateLimiting]
public IActionResult HealthCheck() => Ok("healthy");
Exempt by path convention
Check path in the partition factory:
options.AddPolicy("per-ip-with-exemptions", httpContext =>
{
var path = httpContext.Request.Path.Value ?? "";
// Exempt health checks, feeds, and sitemaps
if (path.StartsWith("/healthz") ||
path.EndsWith("/feed") ||
path == "/sitemap.xml")
{
return RateLimitPartition.GetNoLimiter<string>("exempt");
}
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "strict";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: clientIp,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1)
});
});
Common exemption candidates
| Endpoint type | Exempt? | Rationale |
|---|---|---|
| Health checks | Yes | Monitoring systems poll frequently |
| RSS/Atom feeds | Yes | Feed readers poll; content is cached |
| Sitemaps | Yes | Search engines crawl; content is cached |
| Static assets | Yes | Already served before rate limiter |
| Webhooks | Maybe | Depends on source trust |
Customizing 429 responses
Basic status code
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
Retry-After header
Retry-After is only available when the limiter can estimate when permits are replenished (token bucket, fixed window, sliding window). The concurrency limiter can't provide a reliable retry time, so the metadata may not be present.
options.OnRejected = async (context, cancellationToken) =>
{
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.WriteAsync(
"Too many requests. Please try again later.",
cancellationToken);
};
Logging rejected requests
options.OnRejected = (context, _) =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiting");
logger.LogWarning(
"Rate limited: {Method} {Path} from {IP}",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path,
context.HttpContext.Connection.RemoteIpAddress);
return ValueTask.CompletedTask;
};
Applying policies
To all endpoints
app.MapControllers().RequireRateLimiting("per-ip");
To specific controllers
[EnableRateLimiting("per-ip")]
public class ApiController : ControllerBase { }
To specific actions
[EnableRateLimiting("strict")]
public IActionResult ExpensiveOperation() { }
[DisableRateLimiting]
public IActionResult CheapOperation() { }
Middleware position
UseRateLimiter must come after UseRouting to use endpoint-specific policies/attributes. If your partition key uses authentication info (per-user limits), ensure authentication runs before rate limiting.
app.UseRouting();
app.UseAuthentication();
app.UseRateLimiter(); // After routing
app.UseAuthorization();
Copy/paste artifact: production rate limiter
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// General per-IP policy
options.AddPolicy("per-ip", httpContext =>
{
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString();
if (string.IsNullOrEmpty(clientIp))
{
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: "missing-ip",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 1,
Window = TimeSpan.FromMinutes(1)
});
}
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: clientIp,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
// Logging on rejection
options.OnRejected = (context, _) =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiting");
logger.LogWarning(
"Rate limited: {Method} {Path} from {IP}",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path,
context.HttpContext.Connection.RemoteIpAddress);
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
return ValueTask.CompletedTask;
};
});
Common failure modes
| Symptom | Likely cause |
|---|---|
| All clients share one limit | Missing or wrong partition key |
| Rate limiting bypassed | RateLimiter before Routing; attributes not effective |
| Wrong client IP | ForwardedHeaders misconfigured or missing |
| Legitimate clients blocked | Shared IP (NAT, corporate proxy) |
| Health checks failing | Health endpoints not exempted |
Checklist
- Algorithm chosen based on traffic pattern.
- Partition key uses reliable client identifier.
- Missing IP handled with strict fallback.
- Health checks and feeds exempted.
- 429 response includes Retry-After header.
- Rejected requests logged for monitoring.
- UseRateLimiter after UseRouting.
- ForwardedHeaders configured if behind proxy.
FAQ
Does rate limiting protect against DDoS?
No. Rate limiting helps with application-level abuse but cannot handle volumetric DDoS attacks. Use infrastructure-level protection (Azure WAF, Cloudflare, AWS Shield).
Should I use queuing?
Generally no for web applications. Queuing delays responses, which is usually worse than immediate rejection. Consider queuing only for background job APIs.
How do I test rate limits?
Use load testing tools (k6, JMeter) to verify limits work correctly. Test both under-limit and over-limit scenarios.
What about distributed rate limiting?
The built-in middleware uses in-memory storage. For multi-server deployments, consider Redis-backed implementations or API gateway rate limiting.
How do I handle shared IPs (NAT)?
This is a trade-off. Per-IP limiting may block legitimate users behind corporate proxies. Consider higher limits, user-based limiting for authenticated requests, or API keys.
What to do next
Add rate limiting to your ASP.NET Core application using the fixed window algorithm with per-IP partitioning. Ensure ForwardedHeaders is configured correctly and health check endpoints are exempted.
For more on middleware configuration, read Middleware Pipeline Order.
If you want help implementing rate limiting for your application, reach out via Contact.
References
- Rate limiting middleware in ASP.NET Core
- AddRateLimiter (ASP.NET Core API)
- Rate-limiting middleware requires AddRateLimiter
- Configure ASP.NET Core to work with proxy servers and load balancers
- Announcing Rate Limiting for .NET
- System.Threading.RateLimiting
Author notes
Decisions:
- Recommend fixed window for most cases. Rationale: simplest to understand; sliding window benefits rarely justify complexity.
- Emphasize strict fallback for missing IPs. Rationale: permissive fallback creates bypass vulnerability.
- Show exemption patterns for feeds and health checks. Rationale: common requirement that is often missed.
Observations:
- Teams often forget to exempt health checks, causing monitoring alerts.
- Missing IP handling is the most common security gap in rate limiting implementations.
- Per-IP limiting can be problematic for users behind corporate NAT; per-user is better when authentication is available.