How to implement Rate Limiting in an ASP.NET Core Web API

How to implement Rate Limiting in an  ASP.NET Core Web API

In this article I will learn you how to implement Rate Limiting in an ASP.NET Core Web API. I recently came across a forum where a few users were talking about Rate Limiting and how it could be added in an API, all because the creator had been experiencing an issue, were the server were pushed to much and broke down.

Rate Limiting is the answer and is a way for developers to control the number of allowed requests for a resource within a specific time window. The way to do it is registering each unique IP address and give that a limitation on the number of allowed requests to an API endpoint.

Luckily for us someone else have already had the same issue before and created a NuGet package to handle this problem. It’s called AspNetCoreRateLimit and is able to add rate limits at the client IP and Id. When you are done reading this article you will know how to configure Rate Limiting Middleware by using the IP address using appsettings.json to load in the configuration for easy maintenance.

What is Rate Limiting?

Rate Limiting is a strategy software developers can use to limit network traffic. This kind of middleware is adding a cap on how often someone repeatedly can request an action within a predefined timeframe.

An example could be when a users is trying to log in to an application without success. Rate Limiting middleware would stop this kind of malicious activity on the service. It also helps us reduce strain on web servers, allowing us to reduce costs.

How does Rate Limiting work?

Rate Limiting software is running within the application itself and not on the server. In a normal case rate limiting is based on tracking the client IP where the requests are sent from and tracking how much time elapses between each request.

The rate limiting middleware is measuring the amount of time between each request from the client IP, and also measures the number of requests within a pre-specified timeframe (a rule). If the application is getting too many requests from a single client IP in the defined timeframe, the rate limiting middleware will not fulfill the request from the client IP until the reset point is reached. Normally this will be supplied in the response header to the client.

In this article we will return a message to the client saying “API calls quota exceeded! maximum admitted x per xx“, where x is the amount of allowed requests and xx is the timeframe. An analogy would be a police officer pulling over a driver for exceeding the roads speed limit.

How does Rate Limiting work with APIs?

Every time an API is responding to client request, the owner of the API has to pay for the compute time. Compute time is the resources required for the code to run at the server and produce a response to the given client.

Because there is a cost for the API provider they often have an interest in minimizing the cost, which leads to rate limiting. By making limits we can make sure that no third-party consumer/developer is not overusing the API giving us a huge cost or degrading the service for other consumers.

By using rate limiting we can also motivate third-party developers to pay more for leveraging our API(s). Create a package that allows a certain number of requests within a timeframe and put a price on it.

Another benefit of implementing rate limiting is that we help our API to be protected against malicious bot attacks. An attacker can create a network of bots, that can make to many requests that will result in our API being pushed too much and break, making it unavailable to other consumers. This is referred to as DoS or DDoS attacks.

Add Rate Limiting based on Client IP

Let’s implement IP Rate Limiting in our application. First we have to install the NuGet Package named AspNetCoreRateLimit. To get started, you have to create a new Asp.NET Core Web API based on the template in Visual Studio.

Install AspNetCoreRateLimit NuGet

AspNetCoreRateLimit is an ASP.NET Core rate limiting solution designed to control the rate of requests that clients can make to a Web API or MVC app based on IP address or client ID

# Package Manager
Install-Package AspNetCoreRateLimit

# .NET CLI
dotnet add package AspNetCoreRateLimit

# PackageReference
<PackageReference Include="AspNetCoreRateLimit" Version="VERSION-HERE" />

Extend AppSettings.json with IpRateLimitingSettings

Instead of hardcoding the configuration in our C# code for our Rate Limiting middleware, we can extend appsettings.json with some properties for configuring IpRateLimitOptions. Copy below code and override appsettings.json in the root of your project.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "IpRateLimitingSettings": {
    "EnableEndpointRateLimiting": true,
    "StackBlockedRequests": false,
    "RealIpHeader": "X-Real-IP",
    "ClientIdHeader": "X-ClientId",
    "HttpStatusCode": 429,
    "GeneralRules": [
      {
        "Endpoint": "*",
        "Period": "10s",
        "Limit": 5
      }
    ]
  }
}

Above code is what I call the default configuration for this package to run properly. We will be extending it later in this article to include options for whitelisting IPs, endpoints and clients. We will also take a quick look at how to add general rules for rate limiting.

A brief explanation of IpRateLimitingOptions

  • EnableEndpointRateLimiting – if this property is set to false, then the limits will apply globally in the application. Example: If you set a limit of 10 calls per second, any HTTP call to any endpoint in the application will count towards that limit. If set to true, the client is able to call {HTTP_METHOD}{PATH} 10 times per second for each HTTP method (GET, POST, PUT, DELETE).
  • StackBlockedRequests – If this is set to true, the rejected requests count towards the other limits in the application. If set to false the rejected calls are not added to the throttle counter in the application. Example: If a client makes 5 requests per second and you have configured a limit of 1 request per second, the other limits like per minute, hour, day, month, etc… will only record the first call – the one that was not blocked.
  • RealIpHeader – We can use this to extract the client IP when our Kestrel Server is behind a reverse proxy. If you know that your proxy server uses a different header key for the client IP, then you X-Real-IP will use this option to configure it.
  • ClientIdHeader – We can use this to extract the client id for white listing. If this id is present in the header and matches a value we have added in our ClientWhitelist, then no rate limits will be applied. This is good for using rate limiting in development environments.

Defining Rate Limit Rules

We can have multiple rules, defining the limits for our application. A rule is composed of an endpoint, period, and limit. Here is an explanation of the format:

  • Endpoint – Format is always {HTTP_METHOD}{PATH} – if you want to target all HTTP methods, you can use an asterix “*”.
  • Period – The format is always {INT}{TYPE}. You got the following options to append at each time:
    • s (second)
    • m (minute)
    • h (hour)
    • d (day)
  • Limit – The format is of type long.

General Rule Example

5 calls per 10 seconds

In the example below we are rate limiting all endpoints in the application to 5 calls per 10 seconds:

{
 "Endpoint": "*",
 "Period": "10s",
 "Limit": 5
}
5 calls per 1 minute to specific endpoint with GET

In the example below we are rate limiting GET /api/weatherforecast to 5 calls per 1 minute.

{
 "Endpoint": "get:/api/weatherforecast",
 "Period": "1m",
 "Limit": 5
}
10 calls per 30 seconds to specific endpoint using all HTTP methods

In the rule below we will limit all type of HTTP requests for /api/status to 10 calls per 30 seconds.

{
 "Endpoint": "*:/api/status",
 "Period": "30s",
 "Limit": 10
}

Implement Rate Limiting Middleware

Now for the fun part. Let’s write some code that can turn the above theory to working functionality. I have created a new folder in the root of my Demo API named Middleware and a new folder inside that named RateLimiting.

Inside RateLimiting I have added a new class named RateLimitingMiddleware.cs. This class will be an extension of IServiceCollection and IApplicationBuilder allowing us to have a more clean Program.cs class.

First we start off by implementing the IServiceCollection were we add Memory cache to store rate limit counters and our IP rules defined in appsettings.json. Then we configure IpRateLimitOptions with the IpRateLimitingSettings from appsettings.json and bind them.

using AspNetCoreRateLimit;

namespace RateLimitingDemo.Middleware.RateLimiting
{
    internal static class RateLimitingMiddleware
    {
        internal static IServiceCollection AddRateLimiting(this IServiceCollection services, IConfiguration configuration)
        {
            // Used to store rate limit counters and ip rules
            services.AddMemoryCache();

            // Load in general configuration from appsettings.json
            services.Configure<IpRateLimitOptions>(options => configuration.GetSection("IpRateLimitingSettings").Bind(options));

            // Inject Counter and Store Rules
            services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
            services.AddInMemoryRateLimiting();
            
            // Return the services
            return services;
        }

        internal static IApplicationBuilder UseRateLimiting(this IApplicationBuilder app)
        {
            app.UseIpRateLimiting();
            return app;
        }
    }
}

At the end we create a new method named UseRateLimiting that returns an interface of ApplicationBuilder. This method is very simple – we just register that we would like to use IpRateLimiting in our application.

If you got a load balancer in front of your application, you have to use IDistributedCache with Redis or SQLServer. By doing this we can ensure that all kestrel instances have the same rate limit store at all time. You would just need to change the injection of the counter and store rules to a distributed cache store, like I have done below (should be done a line 16-17 above):

// Inject Counter and Store Rules using Distributed Cache Store
services.AddSingleton<IRateLimitCounterStore, DistributedCacheRateLimitCounterStore>();
services.AddDistributedRateLimiting();

Register Rate Limiting Middleware in Program.cs

Now that we got our middleware in place, we have to wire it up in Program.cs to use it at runtime.

using RateLimitingDemo.Middleware.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Add Rate Limiting
builder.Services.AddRateLimiting(builder.Configuration);

var app = builder.Build();

// Use Rate Limiting
app.UseRateLimiting();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

As you can see we have a way cleaner Program.cs file because we extracted the implementation of our Rate Limiting Middleware to a separate class.

Testing Rate Limiting with the default settings

Spin up the application and spam the Execute button. At the sixth try, you should get this error “API calls quota exceeded! maximum admitted x per xx” – with HTTP Code 429 (Too Many Requests).

And the console would output:

Override General Rules with specific IPs in appsettings.json

At some moments we want to include rate limit rules for specific IPs or IP Scopes. I would use a scope if the application were to be used inside an organization. Below is the json code you would need to implement specific rules for either an IP or an IP Scope.

"IpRateLimitingPolicies": {
    "IpRules": [
      {
        "Ip": "84.354.81.112",
        "Rules": [
          {
            "Endpoint": "*",
            "Period": "1s",
            "Limit": 10
          },
          {
            "Endpoint": "*",
            "Period": "1d",
            "Limit": 250
          }
        ]
      },
      {
        "Ip": "192.168.1.22/24",
        "Rules": [
          {
            "Endpoint": "*",
            "Period": "1s",
            "Limit": 10
          },
          {
            "Endpoint": "*",
            "Period": "30m",
            "Limit": 175
          },
          {
            "Endpoint": "*",
            "Period": "24h",
            "Limit": 1000
          }
        ]
      }
    ]
  }

Head back to the RateLimitingMiddleware.cs file and update the method named AddRateLimiting with the following code:

internal static IServiceCollection AddRateLimiting(this IServiceCollection services, IConfiguration configuration)
{
    // Used to store rate limit counters and ip rules
    services.AddMemoryCache();

    // Load in general configuration and ip rules from appsettings.json
    services.Configure<IpRateLimitOptions>(options => configuration.GetSection("IpRateLimitingSettings").Bind(options));
    services.Configure<IpRateLimitPolicies>(options => configuration.GetSection("IpRateLimitingPolicies").Bind(options));

    // Inject Counter and Store Rules
    services.AddSingleton<IIpPolicyStore, MemoryCacheIpPolicyStore>();
    services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
    services.AddInMemoryRateLimiting();

    // Return the services
    return services;
}

There we go – now our application can be very specific in the rate limiting rules. As you can see it is easy to extend the rules because they use the same pattern. The IP field in the json code supports both IP v4 and v6 values + ranges like:

  • 192.168.1.22
  • 192.168.0.0/24
  • 2001:db8:1234::/48
  • 192.168.1.22-192.168.1.30

The options are many!

Rate Limit on custom Keys in the header

We can parse many keys in the header, and those keys could also be used to rate limit the client in their requests. Below is a custom implementation of IRateLimitConfiguration that will tell AspNetCoreRateLimit how to group requests.

Create a new file named CustomRateLimitConfiguration.cs inside the Middleware/RateLimiting/ folder and place the following code inside it.

As you can see in the ResolveClientAsync method we are just extracting the CustomKey Key value and returning it using a Query.

using AspNetCoreRateLimit;
using Microsoft.Extensions.Options;

namespace RateLimitingDemo.Middleware.RateLimiting
{
    internal class CustomRateLimitConfiguration : RateLimitConfiguration
    {
        public CustomRateLimitConfiguration(
            IOptions<IpRateLimitOptions> ipOptions, 
            IOptions<ClientRateLimitOptions> clientOptions) : base(ipOptions, clientOptions)
        {
        }

        public override void RegisterResolvers()
        {
            ClientResolvers.Add(new ClientIdResolverContributor());
        }
    }

    internal class ClientIdResolverContributor : IClientResolveContributor
    {
        public Task<string> ResolveClientAsync(HttpContext httpContext)
        {
            return Task.FromResult<string>(httpContext.Request.Query["CustomKey"]);
        }
    }
}

Now back in our Rate Limiting Middleware method named AddRateLimiting, we have to update our injection of IRateLimitConfiguration to use our custom limit configuration.

internal static IServiceCollection AddRateLimiting(this IServiceCollection services, IConfiguration configuration)
{
    // Used to store rate limit counters and ip rules
    services.AddMemoryCache();

    // Load in general configuration and ip rules from appsettings.json
    services.Configure<IpRateLimitOptions>(options => configuration.GetSection("IpRateLimitingSettings").Bind(options));
    services.Configure<IpRateLimitPolicies>(options => configuration.GetSection("IpRateLimitingPolicies").Bind(options));

    // Inject Counter and Store Rules
    services.AddSingleton<IIpPolicyStore, MemoryCacheIpPolicyStore>();
    services.AddSingleton<IRateLimitConfiguration, CustomRateLimitConfiguration>();
    services.AddInMemoryRateLimiting();

    // Return the services
    return services;
}

Testing Rate Limiting with Custom Limit Configuration

Let’s spin it up and check if we can get it to exceed the quota when using a specific header key. For this to work, I will be using Postman as tool to test the API.

Visual Studio inspection shows that we extracted the key from the header and that we can use it in combination with our rate limiting middleware.

And the API behaved exactly as I wanted it to. When changing the value for our custom key, the rate limiting was open again.

Updating Rate Limits at runtime

How is that possible when we hardcoded the rules in json that has already been loaded at startup of the application into the cache?

We can access the IP Policy Store inside a controller and modify the IP rules. This way we can launch the application without any IP Rules and then apply them from a database by pushing the to the caching after the app has started.

A way to do it is shown below with async calls:

using AspNetCoreRateLimit;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

namespace RateLimitingDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class RateLimitingController : ControllerBase
    {
        private readonly IpRateLimitOptions _options;
        private readonly IIpPolicyStore _policyStore;

        public RateLimitingController(
            IOptions<IpRateLimitOptions> options,
            IIpPolicyStore policyStore)
        {
            _options = options.Value;
            _policyStore = policyStore;
        }

        [HttpGet]
        public async Task<IpRateLimitPolicies> GetIpRateLimitPolicies()
        {
            // Return IP Rate Limit Policies
            IpRateLimitPolicies? policies = await _policyStore.GetAsync(_options.IpPolicyPrefix);
            return policies;
        }

        [HttpPost]
        public async Task AddIpRateLimitPolicies()
        {
            // Get the policies
            IpRateLimitPolicies? policies = await _policyStore.GetAsync(_options.IpPolicyPrefix);

            if (policies != null)
            {
                // Add a new Ip Rule at runtime
                policies.IpRules.Add(new IpRateLimitPolicy
                {
                    Ip = "1.1.1.1",
                    Rules = new List<RateLimitRule>(new RateLimitRule[] 
                    {
                        new RateLimitRule
                        {
                            Endpoint = "*:/api/update",
                            Limit = 10,
                            Period = "1d"
                        }
                    })
                });

                // Set the new policy
                await _policyStore.SetAsync(_options.IpPolicyPrefix, policies);
            }
        }
    }
}

There you have it. A live way to add new IP rules at runtime. You should just change the parameter in the POST method to take a new IpRateLimitPolicy as input from the body.

Summary

In this article, you learned how to implement and configure rate-limiting in ASP.NET Core. Using rate-limiting middleware is a big help in avoiding unnecessary pressure against the API server resulting in lower costs.

If you are developing applications for a microservice architecture, you should be using the distributed cache method and update the IP store when the application is launched. A scalable approach would be to use an API Gateway in front to handle incoming requests. At the gateway, we can implement the logic for client identification and limits for the internal and external services.

I hope you learned something new from this article about rate limiting. If you got any issues, questions, or suggestions, please let me know in the comments. Happy coding!

Source Code

You are welcome to check out the source code at my Github and follow me if you want to. Rate Limiting Demo – Github.

Leave a Comment

Contact

Odense, Denmark

Contact Me

Connect

Subscribe

Join my email list to receive the latest updates.