Asp.Net Core Security Rate Limit Created: 24 Jan 2026 Updated: 24 Jan 2026

Per-User Rate Limiting Strategy

Table of Contents

  1. Introduction
  2. How It Works
  3. Implementation
  4. Partitioning Strategies
  5. Multi-Tenant Applications
  6. Memory Management
  7. When to Use
  8. Complete Example

Introduction

Per-User Rate Limiting creates independent rate limit buckets for each user, IP address, or tenant. This ensures fair resource allocation and prevents the "noisy neighbor" problem where one user consumes all resources.

The Problem It Solves:

Global Rate Limiter (100 requests/min shared):
User A (Power User): ████████████████████ 90 requests
User B (Regular): █ 5 requests
User C (Regular): █ 5 requests

Result: 99% of users get poor experience! 😞

Per-User Rate Limiter (30 requests/min each):
User A: ██████████ 30 requests (their max)
User B: ██████████ 30 requests (their max)
User C: ██████████ 30 requests (their max)

Result: Fair access for everyone! 😊

Why Per-User Limiting?

  1. Fair resource allocation - Each user gets equal quota
  2. Prevents abuse - One user can't affect others
  3. Better UX - Predictable performance per user
  4. Multi-tenant ready - Perfect for SaaS applications

How It Works

Partitioning Concept

Traditional (Global) Rate Limiter:
┌─────────────────────────────────────┐
│ Single Bucket (100 requests/min) │
│ All users share this bucket │
│ ████████████████████████████████ │
└─────────────────────────────────────┘

Per-User (Partitioned) Rate Limiter:
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ User A Bucket │ │ User B Bucket │ │ User C Bucket │
│ (30 req/min) │ │ (30 req/min) │ │ (30 req/min) │
│ ██████████ │ │ ██████████ │ │ ██████████ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
Independent Independent Independent

Core Algorithm

State:
- buckets: Dictionary<UserKey, RateLimiter>

For each request from user U:
1. Extract user identifier
user_key = GetUserKey(request) // IP, UserID, API Key, etc.
2. Get or create user's bucket
if not exists(buckets[user_key]):
buckets[user_key] = CreateLimiter(config)
limiter = buckets[user_key]
3. Check user's limit
result = limiter.CheckRequest()
return result // ALLOW or REJECT

Visual Timeline

Multi-User Timeline:

User A makes 30 requests at 00:00:
00:00 → User A: 30/30 ✓ (uses full quota)

User B makes 5 requests at 00:00:
00:00 → User B: 5/30 ✓ (independent bucket)

User C makes 50 requests at 00:00:
00:00 → User C: 30/30 ✓, 20 rejected ❌

Result:
- User A: Got their full 30 ✓
- User B: Got their 5 ✓
- User C: Got their 30 ✓ (20 rejected but doesn't affect others)
- Fair allocation maintained!

Implementation

Complete C# Implementation

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

namespace SecurityApp.API.RateLimiting;

/// <summary>
/// Per-User Partitioned Rate Limiter
/// Creates independent rate limit buckets per user/IP/tenant.
/// Ensures fair resource allocation in multi-tenant scenarios.
/// </summary>
public static class PerUserRateLimiter
{
public const string PolicyName = "PerUser";

/// <summary>
/// Configuration:
/// - Each user gets 30 requests per minute
/// - Partitioned by IP address (can be UserID, API Key, etc.)
/// - QueueLimit: 5 per user
///
/// Benefits:
/// - Fair resource distribution
/// - One user can't block others
/// - Independent rate limits
///
/// Use Cases:
/// - Multi-tenant SaaS
/// - Public APIs
/// - Freemium services
/// </summary>
public static void Configure(RateLimiterOptions options)
{
options.AddPolicy(PolicyName, httpContext =>
{
// Get user identifier (IP address by default)
var userIdentifier = httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";

// Create independent rate limiter for this user
return RateLimitPartition.GetFixedWindowLimiter(
userIdentifier,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 5
});
});
}
}

Registration

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
// Register Per-User Limiter
PerUserRateLimiter.Configure(options);
// Rejection handler
options.OnRejected = async (context, cancellationToken) =>
{
var ip = context.HttpContext.Connection.RemoteIpAddress;
context.HttpContext.Response.StatusCode = 429;
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "rate_limit_exceeded",
message = "Your personal rate limit has been exceeded.",
user_identifier = ip?.ToString() ?? "unknown",
limit = 30,
window = "1 minute",
strategy = "per_user",
hint = "Each user has independent rate limits"
}, cancellationToken);
};
});

var app = builder.Build();
app.UseRateLimiter();

// Apply to endpoints
app.MapGet("/api/data", () => new { data = "value" })
.RequireRateLimiting(PerUserRateLimiter.PolicyName);

app.Run();

Partitioning Strategies

Strategy 1: By IP Address (Default)

public static void ConfigureByIP(RateLimiterOptions options)
{
options.AddPolicy("by-ip", httpContext =>
{
var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString()
?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
ipAddress,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 50,
Window = TimeSpan.FromMinutes(1)
});
});
}

// Pros: Simple, no authentication required
// Cons: VPN users share IP, can be bypassed

Strategy 2: By User ID (Authenticated)

public static void ConfigureByUserId(RateLimiterOptions options)
{
options.AddPolicy("by-user-id", httpContext =>
{
// Get authenticated user ID
var userId = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(
userId,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
});
});
}

// Pros: Accurate per-user tracking, can't be bypassed
// Cons: Requires authentication

Strategy 3: By API Key

public static void ConfigureByApiKey(RateLimiterOptions options)
{
options.AddPolicy("by-api-key", httpContext =>
{
// Get API key from header
var apiKey = httpContext.Request.Headers["X-API-Key"].FirstOrDefault()
?? "no-key";
return RateLimitPartition.GetFixedWindowLimiter(
apiKey,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 1000,
Window = TimeSpan.FromMinutes(1)
});
});
}

// Pros: Easy for API consumers, trackable
// Cons: Key management required

Strategy 4: By Tenant ID (Multi-Tenant)

public static void ConfigureByTenant(RateLimiterOptions options)
{
options.AddPolicy("by-tenant", httpContext =>
{
// Get tenant ID from claim or header
var tenantId = httpContext.User.FindFirst("tenant_id")?.Value
?? httpContext.Request.Headers["X-Tenant-ID"].FirstOrDefault()
?? "default";
return RateLimitPartition.GetFixedWindowLimiter(
tenantId,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 500, // Per tenant
Window = TimeSpan.FromMinutes(1)
});
});
}

// Pros: Fair tenant allocation, prevents cross-tenant impact
// Cons: Requires tenant resolution logic

Strategy 5: Composite Key

public static void ConfigureComposite(RateLimiterOptions options)
{
options.AddPolicy("composite", httpContext =>
{
// Combine multiple identifiers
var userId = httpContext.User.Identity?.Name ?? "anonymous";
var endpoint = httpContext.Request.Path.Value ?? "/";
var compositeKey = $"{userId}:{endpoint}";
return RateLimitPartition.GetFixedWindowLimiter(
compositeKey,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1)
});
});
}

// Pros: Granular control per user per endpoint
// Cons: More memory usage, complex keys

Multi-Tenant Applications

SaaS Application Example

// Scenario: Multi-tenant SaaS with different plans

public static void ConfigureSaaS(RateLimiterOptions options)
{
options.AddPolicy("saas-multi-tenant", httpContext =>
{
// Get tenant ID and plan
var tenantId = httpContext.User.FindFirst("tenant_id")?.Value ?? "unknown";
var plan = httpContext.User.FindFirst("subscription_plan")?.Value ?? "free";
// Get limits based on plan
var limits = GetLimitsForPlan(plan);
return RateLimitPartition.GetFixedWindowLimiter(
$"tenant:{tenantId}",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = limits.RequestsPerMinute,
Window = TimeSpan.FromMinutes(1),
QueueLimit = limits.QueueSize
});
});
}

private static (int RequestsPerMinute, int QueueSize) GetLimitsForPlan(string plan)
{
return plan.ToLower() switch
{
"free" => (100, 5),
"basic" => (1000, 20),
"pro" => (10000, 50),
"enterprise" => (100000, 200),
_ => (100, 5)
};
}

// Usage
app.MapGet("/api/data", GetData)
.RequireAuthorization()
.RequireRateLimiting("saas-multi-tenant");

Per-Tenant Database Isolation

// Ensure one tenant can't exhaust database connections

public static void ConfigureTenantDatabase(RateLimiterOptions options)
{
options.AddPolicy("tenant-db", httpContext =>
{
var tenantId = httpContext.User.FindFirst("tenant_id")?.Value ?? "default";
// Each tenant gets independent DB query limit
return RateLimitPartition.GetConcurrencyLimiter(
$"db:{tenantId}",
_ => new ConcurrencyLimiterOptions
{
PermitLimit = 10, // 10 concurrent DB queries per tenant
QueueLimit = 50
});
});
}

app.MapGet("/api/reports", async (AppDbContext db) =>
{
// Heavy database query
var data = await db.Reports.ToListAsync();
return Results.Ok(data);
})
.RequireRateLimiting("tenant-db");

Memory Management

Memory Usage Analysis

Per-User Rate Limiter Memory:

Overhead per user partition:
- Partition key: ~50 bytes (string)
- Rate limiter state: ~100 bytes
- Total per user: ~150 bytes

Scenarios:

1. Small API (1,000 active users):
Memory: 1,000 × 150 bytes = 150 KB ✓ Excellent

2. Medium API (100,000 active users):
Memory: 100,000 × 150 bytes = 15 MB ✓ Good

3. Large API (1,000,000 active users):
Memory: 1,000,000 × 150 bytes = 150 MB ⚠️ Acceptable

4. Very Large API (10,000,000 active users):
Memory: 10,000,000 × 150 bytes = 1.5 GB ❌ Problematic

Memory Optimization Strategies

Strategy 1: Partition Expiration

// Expire inactive user partitions

public class ManagedPerUserLimiter
{
private readonly ConcurrentDictionary<string, (RateLimiter Limiter, DateTime LastAccess)>
_limiters = new();
public async Task<RateLimitLease> AcquireAsync(string userId)
{
// Clean up old partitions every 1000 requests
if (_limiters.Count > 10000)
{
CleanupInactivePartitions();
}
var limiter = _limiters.GetOrAdd(userId, key =>
{
var limiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1)
});
return (limiter, DateTime.UtcNow);
});
// Update last access
_limiters[userId] = (limiter.Limiter, DateTime.UtcNow);
return await limiter.Limiter.AcquireAsync();
}
private void CleanupInactivePartitions()
{
var expiry = DateTime.UtcNow.AddMinutes(-5);
var toRemove = _limiters
.Where(kvp => kvp.Value.LastAccess < expiry)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in toRemove)
{
if (_limiters.TryRemove(key, out var limiter))
{
limiter.Limiter.Dispose();
}
}
}
}

Strategy 2: Partition Count Limit

// Limit maximum number of partitions

public static void ConfigureWithLimit(RateLimiterOptions options)
{
var maxPartitions = 100000;
var partitionCount = 0;
var partitionLock = new object();
options.AddPolicy("limited-partitions", httpContext =>
{
var userId = GetUserId(httpContext);
lock (partitionLock)
{
if (partitionCount >= maxPartitions)
{
// Use fallback: group users into buckets
var bucket = Math.Abs(userId.GetHashCode()) % 1000;
userId = $"bucket:{bucket}";
}
else
{
partitionCount++;
}
}
return RateLimitPartition.GetFixedWindowLimiter(
userId,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1)
});
});
}

Strategy 3: Distributed Cache (Redis)

// Use Redis for large-scale per-user limiting

public class RedisPerUserLimiter
{
private readonly IConnectionMultiplexer _redis;
public async Task<bool> AllowRequestAsync(string userId, int limit, TimeSpan window)
{
var db = _redis.GetDatabase();
var key = $"ratelimit:{userId}";
// Use Redis sorted set with sliding window
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var windowStart = now - (long)window.TotalSeconds;
var transaction = db.CreateTransaction();
// Remove old entries
transaction.SortedSetRemoveRangeByScoreAsync(key, 0, windowStart);
// Count current window
var countTask = transaction.SortedSetLengthAsync(key);
// Add current request
transaction.SortedSetAddAsync(key, now, now);
// Set expiration
transaction.KeyExpireAsync(key, window);
await transaction.ExecuteAsync();
var count = await countTask;
return count <= limit;
}
}

// Benefits:
// - Shared across multiple server instances
// - Automatic expiration
// - Scalable to millions of users

When to Use

✅ Perfect For

1. Multi-Tenant SaaS

// Each tenant gets independent limits
app.MapGet("/api/data", GetData)
.RequireRateLimiting("PerUser"); // By tenant

2. Public APIs

// Prevent one user from blocking others
app.MapGet("/api/public/search", Search)
.RequireRateLimiting("PerUser"); // By IP or API key

3. Freemium Services

// Different limits per user tier
app.MapGet("/api/premium/data", GetPremiumData)
.RequireRateLimiting("PerUser"); // By user subscription

4. Fair Usage Enforcement

// Ensure equal access for all
app.MapPost("/api/submit", Submit)
.RequireRateLimiting("PerUser");

❌ Avoid When

1. Internal APIs (Single Tenant)

// All traffic from same source
app.MapGet("/internal/health", HealthCheck)
.RequireRateLimiting("FixedWindow"); // Simpler

// Why: Overhead not worth it for single tenant

2. Low User Count

// Only 10 users
app.MapGet("/admin/dashboard", AdminDashboard)
.RequireRateLimiting("FixedWindow"); // Simpler

// Why: Per-user not needed for small user base

3. Memory Constrained

// Millions of users, limited memory
// Consider: Global limit or Redis-based solution

Complete Example

Full Production Setup

// Program.cs
using Microsoft.AspNetCore.RateLimiting;
using System.Security.Claims;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();

builder.Services.AddRateLimiter(options =>
{
// Per-User for authenticated endpoints
options.AddPolicy("per-user-auth", httpContext =>
{
var userId = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? "anonymous";
// Get user plan
var plan = httpContext.User.FindFirst("plan")?.Value ?? "free";
var limit = plan switch
{
"free" => 50,
"basic" => 200,
"pro" => 1000,
_ => 50
};
return RateLimitPartition.GetSlidingWindowLimiter(
userId,
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 10
});
});
// Per-IP for public endpoints
options.AddPolicy("per-ip-public", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(
ip,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 5
});
});
// Per-Tenant for multi-tenant endpoints
options.AddPolicy("per-tenant", httpContext =>
{
var tenantId = httpContext.User.FindFirst("tenant_id")?.Value
?? httpContext.Request.Headers["X-Tenant-ID"].FirstOrDefault()
?? "default";
return RateLimitPartition.GetTokenBucketLimiter(
tenantId,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 500,
TokensPerPeriod = 100,
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 50,
AutoReplenishment = true
});
});
// Comprehensive rejection handler
options.OnRejected = async (context, ct) =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
var userId = context.HttpContext.User.Identity?.Name
?? context.HttpContext.Connection.RemoteIpAddress?.ToString()
?? "unknown";
logger.LogWarning(
"Rate limit exceeded: User={User}, Path={Path}",
userId, context.HttpContext.Request.Path);
context.HttpContext.Response.StatusCode = 429;
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "rate_limit_exceeded",
message = "Your personal rate limit has been exceeded",
user = userId,
retry_after_seconds = (int?)retryAfter?.TotalSeconds ?? 60,
hint = "Each user has independent rate limits"
}, ct);
};
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

// Public endpoints: Per-IP
app.MapGet("/api/public/search", (string query) =>
new { results = $"Search results for: {query}" })
.RequireRateLimiting("per-ip-public");

// Authenticated endpoints: Per-User
app.MapGet("/api/user/data", () => new { data = "user data" })
.RequireAuthorization()
.RequireRateLimiting("per-user-auth");

// Multi-tenant endpoints: Per-Tenant
app.MapGet("/api/tenant/reports", async (AppDbContext db) =>
{
var reports = await db.Reports.ToListAsync();
return Results.Ok(reports);
})
.RequireAuthorization()
.RequireRateLimiting("per-tenant");

app.Run();

Combining with Other Strategies

// Combine Per-User + Concurrency

// Step 1: Per-user rate limit
options.AddPolicy("user-rate", ...);

// Step 2: Global concurrency limit
options.AddConcurrencyLimiter("global-concurrency", opt =>
{
opt.PermitLimit = 100; // Max 100 concurrent across all users
});

// Apply both
app.MapGet("/api/heavy", HeavyOperation)
.RequireRateLimiting("user-rate") // Per-user limit
.RequireRateLimiting("global-concurrency"); // Global limit
Share this lesson: