Role Based Rate Limiting

Introduction

Welcome to the fourth lesson of the Implementing Rate Limiting course! In this lesson, we will explore the concept of role-based rate limiting, a crucial aspect of API security that allows you to provide differentiated service levels to your users.

In real-world applications, not all users are equal. A premium subscriber paying for enhanced access expects better service than a free-tier user. An administrator managing your platform needs unrestricted access to perform their duties effectively. Role-based access control (RBAC) allows us to assign different permissions and access levels to users based on their roles, such as anonymous, standard, premium, and admin users.

This lesson will guide you through implementing and testing role-based rate limiting using Redis as our distributed data store. You'll learn how to extract user roles from authentication tokens, implement a custom rate limiting middleware backed by Redis, and verify that your implementation works correctly. By the end of this lesson, you'll be equipped to tailor API access based on user roles, ensuring both security and optimal resource usage. Let's dive in! 🚀

Understanding Redis for Distributed Rate Limiting

Before we dive into role-based rate limiting, we need to understand Redis - the distributed data store that will power our rate limiting system across multiple servers.

What is Redis?

Redis (Remote Dictionary Server) is an open-source, in-memory key-value store that acts as a super-fast database. Think of it as a shared dictionary that all your servers can access simultaneously. For rate limiting, Redis tracks request counts in a centralized location that persists across server restarts and is accessible to all servers in your application.

Why Redis for Rate Limiting?

The rate limiting solutions we've implemented in previous lessons used in-memory storage with ConcurrentDictionary. This works perfectly for a single server, but production APIs typically run on multiple servers behind a load balancer. Without Redis:

  • Each server tracks its own limits independently - Server A doesn't know about requests handled by Server B
  • Users can bypass limits - A user could send 10 requests to Server A and 10 to Server B, totaling 20 requests when your limit is 10
  • Limits reset on server restart - Redeploying your application clears all rate limit counters
  • Scaling is impossible - Adding more servers makes the problem worse

Redis solves all these problems by providing centralized, persistent storage that all servers share.

Essential Redis Commands for Rate Limiting

We'll use three fundamental Redis commands in our implementation:

1. INCR (Increment) - Atomically increases a counter by 1

var count = await db.StringIncrementAsync(key);
// If the key doesn't exist, Redis creates it with value 1
// If the key exists, Redis increases its value by 1
// Returns the new count value

This command is atomic, meaning Redis guarantees that even if 100 servers call INCR simultaneously, each request will be counted exactly once with no race conditions.

2. EXPIRE - Sets an expiration time for a key

await db.KeyExpireAsync(key, TimeSpan.FromSeconds(30));
// After 30 seconds, Redis automatically deletes this key
// This resets the rate limit window without manual cleanup

Redis handles the deletion automatically in the background, making it perfect for time-windowed rate limiting.

3. TTL (Time To Live) - Checks how long until a key expires

var ttl = await db.KeyTimeToLiveAsync(key);
// Returns the remaining time before Redis deletes the key
// Returns null if the key has no expiration set

We use TTL to calculate the "retry-after" value in our 429 responses, telling users exactly when they can make requests again.

How These Commands Work Together

Here's the flow of a typical rate-limited request:

  1. User makes a request → We construct a key like "ratelimit:standard:user:123"
  2. Increment the counterINCR ratelimit:standard:user:123 returns 1 (first request)
  3. Set expiration on first requestEXPIRE ratelimit:standard:user:123 30 (expires in 30 seconds)
  4. Check the count → If count ≤ limit, allow the request
  5. On subsequent requestsINCR returns 2, 3, 4... until we hit the limit
  6. When limit exceeded → Use TTL to tell the user when to retry
  7. After window expires → Redis automatically deletes the key, resetting the counter

Redis Connection in ASP.NET Core

We'll use the StackExchange.Redis library to connect to Redis:

var redis = ConnectionMultiplexer.Connect("localhost:6379");
var db = redis.GetDatabase();

The connection string "localhost:6379" means:

  • localhost - Redis is running on the same machine (or accessible server)
  • 6379 - The default Redis port

In production, you'd use a cloud Redis service like Azure Redis Cache, AWS ElastiCache, or Redis Cloud, but the API remains identical.

Understanding Role-Based Rate Limiting

Role-based rate limiting is a method of controlling the number of requests a user can make to an API based on their assigned role. Unlike global rate limiting, which applies the same limits to all users, or endpoint-specific rate limiting, which targets particular routes, role-based rate limiting allows for more granular and business-aligned control.

Consider a code snippet sharing platform as an example. Anonymous users browsing without an account might be limited to 5 requests per 30 seconds to prevent abuse. Standard registered users could receive 10 requests, rewarding them for creating an account. Premium subscribers who pay for the service might enjoy 30 requests, while administrators managing the platform could have 100 requests to perform their duties without interruption.

This tiered approach serves multiple purposes. It protects your infrastructure from abuse by limiting anonymous access. It incentivizes users to register and upgrade their accounts. It ensures paying customers receive the premium experience they expect. And it allows administrators to work efficiently without artificial constraints. By implementing a custom Redis-backed middleware, we can implement these tailored limits cleanly and efficiently.

Understanding JWT for Authentication

Before we implement role-based rate limiting, it's important to understand how we'll identify user roles in incoming requests. We'll use JSON Web Tokens (JWT) for this purpose. JWT is an open standard that defines a compact and self-contained way to securely transmit information between parties as a JSON object.

When a user authenticates with your API, they receive a JWT that contains several pieces of information known as claims. These claims include the user's unique identifier, their assigned role (such as admin, premium, or standard), and an expiration timestamp. The entire token is cryptographically signed with a secret key known only to the server, making it secure and tamper-proof.

In our implementation, we'll manually extract and validate JWT tokens in our rate limiting middleware using the System.IdentityModel.Tokens.Jwt package. This gives us complete control over the token validation process and allows us to extract role information before the request reaches our route handlers. The middleware will parse the Authorization header, validate the token's signature and expiration, and extract the role claim to determine the appropriate rate limit.

This claims-based identity system is what makes role-based rate limiting practical. We can query the token's claims to determine the user's role and apply the appropriate rate limit without relying on ASP.NET Core's authentication middleware to run first.

Setting Up Redis and Services

Now let's implement role-based rate limiting using Redis as our distributed data store. We'll configure the application in Program.cs to connect to Redis and register our custom middleware.

First, we need to add the required using statements and configure our services:

using StackExchange.Redis;
using RateLimitingApi.Middleware;

var builder = WebApplication.CreateBuilder(args);

// Register Redis connection as a singleton
builder.Services.AddSingleton<IConnectionMultiplexer>(_ => 
    ConnectionMultiplexer.Connect("localhost:6379"));

// Register our custom Redis rate limiting middleware
builder.Services.AddSingleton<RedisRateLimitingMiddleware>();

The IConnectionMultiplexer is the main entry point for working with Redis using the StackExchange.Redis library. We register it as a singleton because the connection is thread-safe and should be reused throughout the application's lifetime. The RedisRateLimitingMiddleware is our custom middleware class that will handle the rate limiting logic.

Implementing the Redis Rate Limiting Middleware

Now let's create the custom middleware that implements role-based rate limiting with Redis. This middleware will intercept every request, extract the user's role from their JWT token, and use Redis to track and enforce rate limits.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using StackExchange.Redis;

namespace RateLimitingApi.Middleware
{
    public sealed class RedisRateLimitingMiddleware : IMiddleware
    {
        private readonly IDatabase _db;
        private readonly JwtSecurityTokenHandler _jwtHandler;
        private const string JwtSecretKey = "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";

        public RedisRateLimitingMiddleware(IConnectionMultiplexer mux)
        {
            _db = mux.GetDatabase();
            _jwtHandler = new JwtSecurityTokenHandler();
        }

        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            // Extract user role from JWT token
            var (role, userId) = ExtractUserInfoFromToken(context);
            
            // Determine rate limit based on role
            var permitLimit = role switch
            {
                "admin" => 100,
                "premium" => 30,
                "standard" => 10,
                _ => 5 // anonymous
            };
            
            // Create partition key based on role and user identity
            var keyIdentifier = !string.IsNullOrEmpty(userId) 
                ? $"user:{userId}" 
                : $"ip:{context.Connection.RemoteIpAddress}";
            var key = $"ratelimit:{role}:{keyIdentifier}";

            // Use Redis INCR to atomically increment the counter
            var count = await _db.StringIncrementAsync(key);

            // Set expiration on first request in window
            if (count == 1)
            {
                await _db.KeyExpireAsync(key, TimeSpan.FromSeconds(30));
            }

            // Check if limit exceeded
            if (count > permitLimit)
            {
                // Use TTL to calculate retry-after
                var ttl = await _db.KeyTimeToLiveAsync(key);
                var retryAfterSeconds = ttl.HasValue 
                    ? Math.Max(1, (int)Math.Ceiling(ttl.Value.TotalSeconds)) 
                    : 30;

                context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
                context.Response.ContentType = "application/json";
                context.Response.Headers.RetryAfter = retryAfterSeconds.ToString();

                await context.Response.WriteAsJsonAsync(new
                {
                    error = "Too Many Requests",
                    message = "Rate limit exceeded. Please try again later.",
                    retryAfterSeconds
                });
                return;
            }

            await next(context);
        }

Let's break down the key parts of this implementation:

The constructor receives the Redis connection multiplexer through dependency injection and gets a reference to the database. We also create a JWT handler that we'll use to parse and validate tokens.

The InvokeAsync method is the heart of the middleware. It follows the exact flow we outlined earlier when discussing Redis commands. First, we extract the user's role and ID from their JWT token. Then we determine the appropriate rate limit using a switch expression. We construct a unique Redis key that combines the role with either the user ID (for authenticated users) or IP address (for anonymous users).

The Redis operations demonstrate the three commands we learned earlier. We use StringIncrementAsync to atomically increment the counter. If this is the first request in the window (count equals 1), we set the expiration using KeyExpireAsync. When the limit is exceeded, we use KeyTimeToLiveAsync to calculate how long the user should wait before retrying.

Now let's add the method that extracts user information from the JWT token:

        private (string role, string userId) ExtractUserInfoFromToken(HttpContext context)
        {
            var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
            
            if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
            {
                return ("anonymous", string.Empty);
            }

            try
            {
                var token = authHeader.Substring("Bearer ".Length).Trim();
                var key = Encoding.UTF8.GetBytes(JwtSecretKey);

                _jwtHandler.ValidateToken(token, new TokenValidationParameters
                {
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = new SymmetricSecurityKey(key),
                    ValidateIssuer = false,
                    ValidateAudience = false,
                    ClockSkew = TimeSpan.Zero
                }, out SecurityToken validatedToken);

                var jwtToken = (JwtSecurityToken)validatedToken;
                var role = jwtToken.Claims
                    .FirstOrDefault(x => x.Type == ClaimTypes.Role)?.Value ?? "standard";
                var userId = jwtToken.Claims
                    .FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier)?.Value ?? string.Empty;

                return (role, userId);
            }
            catch
            {
                return ("anonymous", string.Empty);
            }
        }
    }
}

The ExtractUserInfoFromToken method handles JWT parsing and validation. It first checks if the Authorization header exists and contains a Bearer token. If not, the user is treated as anonymous. The method then validates the token using the secret key and extracts the role and user ID claims. Any validation failures (expired token, invalid signature, etc.) result in the user being treated as anonymous, which applies the most restrictive rate limit.

Applying the Rate Limiter

With the middleware implemented, we need to register it in the application pipeline and define our API endpoint. Update your Program.cs to include the middleware and the minimal API route:

var app = builder.Build();

// Apply our Redis-backed rate limiting middleware
app.UseMiddleware<RedisRateLimitingMiddleware>();

app.MapGet("/api/snippets/premium-content", () =>
    Results.Ok(new { message = "This is premium content with role-based rate limiting" })
);

app.Run();

The middleware is added early in the pipeline so it can intercept requests before they reach the route handlers. Our custom middleware applies to all requests automatically. If you need to exclude certain endpoints from rate limiting, you can add conditional logic in the middleware's InvokeAsync method to check the request path.

Testing Role-Based Rate Limiting

With role-based rate limiting in place, it's time to verify that it works correctly. We'll create a test application that generates JWT tokens for different roles and simulates requests to observe the rate limiting behavior.

First, let's create a helper class to generate JWT tokens with role claims:

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

public class JwtTokenGenerator
{
    private readonly string _secretKey;
    
    public JwtTokenGenerator(string secretKey)
    {
        _secretKey = secretKey;
    }
    
    public string CreateToken(string userId, string role)
    {
        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey));
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
        
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(ClaimTypes.Role, role)
        };
        
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddHours(1),
            SigningCredentials = credentials
        };
        
        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }
}

This class uses the System.IdentityModel.Tokens.Jwt package to create properly signed JWT tokens. Each token includes a user identifier and a role claim, which our rate limiter will extract to determine the appropriate limit.

Now let's create the test program that simulates requests from different user roles:

using System.Net.Http.Headers;

class Program
{
    static async Task Main(string[] args)
    {
        var jwtSecretKey = "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
        var tokenGenerator = new JwtTokenGenerator(jwtSecretKey);
        
        var standardToken = tokenGenerator.CreateToken("user123", "standard");
        var premiumToken = tokenGenerator.CreateToken("user456", "premium");
        var adminToken = tokenGenerator.CreateToken("admin789", "admin");
        
        var roles = new[]
        {
            new { Name = "anonymous", Token = (string?)null, Requests = 7 },
            new { Name = "standard", Token = standardToken, Requests = 12 },
            new { Name = "premium", Token = premiumToken, Requests = 32 },
            new { Name = "admin", Token = adminToken, Requests = 20 }
        };
        
        using var httpClient = new HttpClient();
        
        foreach (var role in roles)
        {
            Console.WriteLine($"\n--- Testing {role.Name} role ---");
            
            for (int i = 1; i <= role.Requests; i++)
            {
                var request = new HttpRequestMessage(HttpMethod.Get, 
                    "http://localhost:5000/api/snippets/premium-content");
                
                if (role.Token != null)
                {
                    request.Headers.Authorization = 
                        new AuthenticationHeaderValue("Bearer", role.Token);
                }
                
                var response = await httpClient.SendAsync(request);
                Console.WriteLine($"Request {i}: {(int)response.StatusCode} {response.ReasonPhrase}");
                
                await Task.Delay(50);
            }
        }
    }
}

The test program creates tokens for standard, premium, and admin roles, then sends a specific number of requests for each role. We intentionally send more requests than each role's limit allows to verify that rate limiting kicks in at the expected threshold.

When you run this test application, you should see output similar to the following:

--- Testing anonymous role ---
Request 1: 200 OK
Request 2: 200 OK
Request 3: 200 OK
Request 4: 200 OK
Request 5: 200 OK
Request 6: 429 Too Many Requests
Request 7: 429 Too Many Requests

--- Testing standard role ---
Request 1: 200 OK
Request 2: 200 OK
...
Request 10: 200 OK
Request 11: 429 Too Many Requests
Request 12: 429 Too Many Requests

--- Testing premium role ---
Request 1: 200 OK
Request 2: 200 OK
...
Request 30: 200 OK
Request 31: 429 Too Many Requests
Request 32: 429 Too Many Requests

--- Testing admin role ---
Request 1: 200 OK
Request 2: 200 OK
...
Request 20: 200 OK

Analyzing this output confirms that our role-based rate limiting is working correctly. Anonymous users receive 429 Too Many Requests after their fifth request, demonstrating the 5 request limit. Standard users hit their limit after 10 requests, and premium users after 30. The admin test only sends 20 requests, all of which succeed because the admin limit of 100 hasn't been reached. This differentiated behavior shows that our Redis-backed rate limiter is correctly tracking each user's request count independently.

Conclusion and Next Steps

In this lesson, we explored the concept of role-based rate limiting and implemented it using Redis as a distributed data store. We learned how Redis provides atomic operations like INCR, EXPIRE, and TTL that are perfect for implementing rate limiting in a multi-server environment. We built a custom middleware that extracts user roles from JWT tokens and applies appropriate limits, then verified our implementation through comprehensive testing.

Role-based rate limiting is a powerful tool for building APIs that serve diverse user bases. It allows you to protect your infrastructure from abuse while still providing excellent service to your valued users. The Redis-backed approach we used ensures that rate limits work correctly even when your API scales across multiple servers, with each user's request count tracked independently in a centralized location.

As you move on to the practice exercises, you'll have the opportunity to apply these concepts hands-on. Try experimenting with different rate limits, adding new roles, or combining role-based limiting with other partitioning strategies. In the upcoming lessons, we'll continue to build on these concepts by exploring additional security measures to protect your API. Keep up the great work, and let's continue to secure our APIs! 🎉

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal