API Rate Limiting by IP in ASP.NET Core

When I build REST API, I often want to control user requests' frequency to prevent the API from being abused. A common approach is to enforce a rate limit on the number of API calls coming from an IP address over some time. The IP rate limit can help lower risks of DoS attacks or make your web-site scraping via a REST API a bit more complicated. In this article, I will show you how to implement rate-limiting in ASP.NET Core 3.1. It is relatively easy nowadays, let's see.

First, let's create a Web API project and implement the test API. Run your favorite shell and create a new project using dotnet tool.

dotnet new webapi -o RateLimiting

Test API has two endpoints: http://localhost:5000/sample/time and http://localhost:5000/sample/status.

[ApiController]
[Route("[controller]")]
public class SampleController : ControllerBase
{
    [HttpGet]
    [Route("time")]
    public TimeResponse GetTime()
    {
        var response = new TimeResponse { Time = DateTime.Now };
        return response;
    }

    [HttpGet]
    [Route("status")]
    public IActionResult GetStatus()
    {
        return Ok("OK");
    }
}

The Time API returns the current local server's time. The Status API simply always returns HTTP 200 OK.

Let's implement the following requirements:

  • The Time API allows only 2 requests/minute per IP. This rate does not make sense in the real world, but it is OK for testing purposes to see the actual rate limiting errors.
  • The Status API has no restrictions.

SP.NET Core has a solution already. The library is called AspNetCoreRateLimit. It is an open-source project hosted on GitHub and available on NuGet. AspNetCoreRateLimit adds rate limit support to ASP.NET Core applications based on the user's IP address or Client ID. In the article, I focus on IP-based rate limits. You can find the Client ID rate limits details in the project's wiki.

Go ahead and add the library to the test project.

dotnet add package AspNetCoreRateLimit

The library comes with IpRateLimitMiddleware middleware that should be configured in the project's Startup.cs file.

public void ConfigureServices(IServiceCollection services)
{
    // 1. add in-memory cache to store rate limit counters and ip rules
    services.AddMemoryCache();

    // 2. load general configuration from appsettings.json
    services.Configure<IpRateLimitOptions>(Configuration.GetSection("IpRateLimiting"));

    // 4. inject counter and rules stores
    services.AddSingleton<IIpPolicyStore, MemoryCacheIpPolicyStore>();
    services.AddSingleton<IRateLimitCounterStore, MemoryCacheRateLimitCounterStore>();

    services.AddControllers();

    // 5. the clientId/clientIp resolvers use IHttpContextAccessor.
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

    // 6. AspNetCoreRateLimit configuration (resolvers, counter key builders)
    services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseHttpsRedirection();

    // 7. enable AspNetCoreRateLimit middleware
    app.UseIpRateLimiting();

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

Keep in mind that you should enable an ASP.NET Core cache. I use the simplest in-memory cache in the example. Also, you need to register IHttpContextAccessor to get the client's IP address.

The next step is adding IpRateLimiting configuration section to your appsettings.json.

"IpRateLimiting": {
    "EnableEndpointRateLimiting": false,
    "StackBlockedRequests": false,
    "RealIpHeader": "X-Real-IP",
    "HttpStatusCode": 429,
    "GeneralRules": [
        {
        "Endpoint": "*",
        "Period": "1m",
        "Limit": 2
        }
    ]
}

If EnableEndpointRateLimiting is set to false, then the limits will apply globally. It means that both GET and POST requests are counted towards 2 req/sec rate limit. If EnableEndpointRateLimiting is set to true, then the limits will apply for each endpoint as in {HTTP_Verb}{PATH}. It means that a user can call 2 GET requests and 2 POST requests to http://localhost:5000/sample/time API.

The RealIpHeader is used to extract the client IP when your Kestrel server is behind a reverse proxy. For example, NGINX uses the X-Real-IP header by default.

You will find more information in the project's wiki.

Everything is ready. Let's run the application and test it.

dotnet run
curl -v http://localhost:5000/sample/time

If the request passes the rate limit, you should see the following HTTP headers in the response.

X-Rate-Limit-Limit: 1m
X-Rate-Limit-Remaining: 0
X-Rate-Limit-Reset: 2020-10-20T09:13:10.6507500Z

Otherwise, the request is blocked, and you will get HTTP 429 Too Many Requests status code.

HTTP/1.1 429 Too Many Requests
Retry-After: 57
API calls quota exceeded! maximum admitted 2 per 1m.

Retry-After HTTP header tells you that you can retry the API call in 57 seconds. You can customize the response by changing HttpStatusCode and QuotaExceededMessage options in IpRateLimiting configuration section.

The rate limit is applied to all APIs in the application, and you cannot use the Status API more than twice in a minute.

curl -v http://localhost:5000/sample/time
HTTP/1.1 429 Too Many Requests
Retry-After: 57
API calls quota exceeded! maximum admitted 2 per 1m.

Let's implement the second requirement: the Status API has not restriction. You should add EndpointWhitelist option to IpRateLimiting configuration section and restart the application.

"EndpointWhitelist": [ "*:/sample/status" ]

Now, if you request http://localhost:5000/sample/status, you will not see X-Rate-Limit-* HTTP headers anymore.

AspNetCoreRateLimit has a lot more advanced scenarios like Client ID rate limits, etc., and I highly recommend checking their documentation.