Rate Limiting in ASP.NET Core: Patterns That Actually Protect

TL;DR Fixed window vs sliding window vs token bucket: choose the right algorithm, partition by IP or user, and handle edge cases like missing IPs and exempt endpoints.

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

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.