Rate Limiting in ASP.NET Core: Protect Your API
A single client, meaning no harm at all, can take down a shared resource just as effectively as an attacker — rate limiting is the guardrail either way.
Imagine your API's /api/search endpoint calls a paid, rate-limited third-party mapping service on every request. One day, a single legitimate client — no malicious intent whatsoever, just a bug in their retry logic — starts firing thousands of requests a second. Your API happily accepts every one of them and forwards each to the mapping service, which promptly rate-limits you, breaks search for every other customer, and hands you a surprise bill. Nothing about this required an attacker. A perfectly well-behaved client with a bug was enough.
Now widen the picture: a genuine attacker deliberately hammering a login endpoint to brute-force passwords, or a script scraping your entire product catalog in seconds, or simply far more traffic from one client than your database can comfortably serve while still being fair to everyone else. All of these are the same underlying problem, wearing different hats: too many requests, from one source, in too little time. Rate limiting is the mechanism that puts a boundary on that.
In this lesson: why APIs need rate limiting, ASP.NET Core's built-in Microsoft.AspNetCore.RateLimiting middleware (part of the framework since .NET 7), its four algorithms — Fixed Window, Sliding Window, Token Bucket, and the Concurrency Limiter — with their genuine trade-offs, and how to configure and apply a policy to real endpoints.
What Is It?
The Simple Explanation
Rate limiting is capping how many requests a client is allowed to make in a given period of time (or how many it can have in flight at once), and rejecting — usually with an HTTP 429 Too Many Requests — whatever exceeds that cap.
The Technical Definition
ASP.NET Core ships a built-in rate limiting middleware, Microsoft.AspNetCore.RateLimiting, available since .NET 7. It's configured with one or more named rate limiter policies — each backed by one of four built-in algorithms (Fixed Window, Sliding Window, Token Bucket, or Concurrency) — registered once via AddRateLimiter(), then applied to specific endpoints or endpoint groups. A request that violates its policy's limit is rejected before reaching your endpoint logic at all, with a configurable response (429 by default).
Why Does It Exist?
The Problem — Unlimited Traffic Hurts Everyone Sharing the System
An API without any rate limiting treats every client as if it could send unlimited traffic without consequence. In practice, that assumption breaks down in three genuinely distinct ways:
- Abuse — an attacker deliberately sending excessive traffic: brute-forcing a login form, scraping data at high speed, or attempting a denial-of-service.
- Unfairness — one client, even without malicious intent, consuming so much capacity that every other client sharing the same API suffers degraded performance.
- Downstream overload — even entirely legitimate, well-intentioned traffic can overwhelm a shared, finite resource behind your API: a database that can only handle so many concurrent queries, or a paid third-party API with its own rate limits and per-call costs, exactly as in the hook.
The Need
What's needed is a way to cap how much traffic any single client can generate — configurable per endpoint, since a login endpoint and a static content endpoint have very different reasonable limits — enforced consistently and cheaply, before that traffic ever reaches the expensive parts of the system.
The Solution
ASP.NET Core's built-in rate limiting middleware, offering four different algorithms because "cap the traffic" is not actually one single problem — a login endpoint's needs (protect against brute-force bursts) are genuinely different from a background sync endpoint's needs (allow occasional bursts, enforce a steady average), and the four algorithms below exist specifically to match those different shapes of need.
Big Picture
↓
Rate limiting middleware — checks the request against its endpoint's assigned policy
↓
Within limit → request proceeds normally, toward authentication/authorization/your endpoint
Over limit → 429 Too Many Requests, rejected immediately — your endpoint never runs, the database is never touched
How It Works
- Divides time into fixed-size windows (e.g. every 60 seconds) and allows up to N requests per window, then resets the count entirely at each window boundary.
- Genuine trade-off: a client can send N requests in the last instant of one window, then immediately send another N requests in the first instant of the next window — briefly, up to 2N requests in a very short span straddling the boundary, even though the configured rate was "N per window."
- Splits each window into smaller segments and tracks request counts per segment, effectively "sliding" the window continuously rather than resetting it all at once — a request is counted against a blend of the current and recent segments.
- Genuine trade-off: it directly fixes fixed window's boundary-burst problem, at the cost of tracking more state (per-segment counts) than a single running counter — a real but usually modest overhead.
- A "bucket" holds up to a maximum number of tokens; each request consumes one token; tokens are replenished at a steady, configured rate over time. A client with a full bucket can burst through several requests at once — up to the bucket's capacity — but is then limited to the steady refill rate until the bucket fills back up.
- Genuine trade-off: this is the most flexible of the count-based algorithms — it deliberately tolerates short bursts (which fixed and sliding window are stricter about) while still enforcing a real long-run average rate, which is exactly why it's a common default choice for general-purpose API rate limiting.
- Instead of counting requests over a time period, this caps how many requests can be actively being processed at the same moment — a request acquires a "slot" when it starts and releases it when it finishes; once every slot is taken, further requests are rejected (or queued) until one frees up.
- Genuine distinction: a client sending requests slowly, one at a time, could technically send an unlimited number over a day under a pure concurrency limiter and never be throttled — because it never has more than one request in flight at once. This is precisely why it's a different kind of limit than the other three, and it's specifically well-suited to protecting a resource that's sensitive to simultaneous load (like a limited connection pool) rather than to total volume over time.
Simple Example
Registering a fixed window policy and applying it to one endpoint:
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.PermitLimit = 10; // up to 10 requests...
opt.Window = TimeSpan.FromSeconds(30); // ...per 30-second window
opt.QueueLimit = 0; // requests over the limit are rejected immediately, not queued
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
var app = builder.Build();
app.UseRateLimiter(); // add the middleware to the pipeline
app.MapGet("/api/weather", () => Results.Ok(GetWeather()))
.RequireRateLimiting("fixed"); // apply the named policy to this endpoint
Meaning: The policy is defined once, by name, and attached to whichever endpoints need it via RequireRateLimiting("fixed") — the same reusable-policy shape you've already seen for authorization policies, applied here to traffic instead of permissions.
Real-World Example
Different endpoints, genuinely different rate-limiting needs — matching each algorithm to the shape of the problem it actually solves:
builder.Services.AddRateLimiter(options =>
{
// Login: protect against brute-force attempts, per client IP — a strict, simple cap is appropriate here.
options.AddFixedWindowLimiter("login", opt =>
{
opt.PermitLimit = 5;
opt.Window = TimeSpan.FromMinutes(1);
});
// General API traffic: allow normal bursts of activity (a user rapidly paging through results)
// while still holding everyone to a fair, steady average rate.
options.AddTokenBucketLimiter("api", opt =>
{
opt.TokenLimit = 20; // bucket capacity — allows a burst of 20
opt.TokensPerPeriod = 5;
opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10); // refills 5 tokens every 10 seconds
});
// Report generation: expensive, resource-heavy work — the concern isn't request volume over time,
// it's how many of these can genuinely run AT ONCE without overwhelming the reporting engine.
options.AddConcurrencyLimiter("reports", opt =>
{
opt.PermitLimit = 3; // at most 3 report generations in flight simultaneously, from anyone
opt.QueueLimit = 10; // additional requests wait in a queue rather than being rejected outright
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsync("Too many requests — please slow down.", token);
};
});
app.UseRateLimiter();
app.MapPost("/api/login", LoginHandler).RequireRateLimiting("login");
app.MapGet("/api/products", GetProducts).RequireRateLimiting("api");
app.MapPost("/api/reports/generate", GenerateReport).RequireRateLimiting("reports");
Three different policies, three different algorithms, because each endpoint's actual risk is genuinely different: login needs strict, simple abuse protection (Fixed Window); everyday API browsing needs to tolerate normal bursty usage while staying fair overall (Token Bucket); expensive report generation needs to cap simultaneous load on a shared resource, regardless of how spread out over time the requests are (Concurrency Limiter).
Analogy
A fixed window is like a ride that only counts riders per exact clock hour — if 50 people rush in during the last minute of the 2 PM hour, and another 50 the instant 3 PM starts, the ride was briefly swamped with 100 people in about two minutes, even though "50 per hour" was the rule. A sliding window smooths that by tracking riders on a continuously moving hour, not a clock-aligned one, so no single sixty-second stretch can be double-counted against two separate hours.
A token bucket is like a ride that hands out ride tickets that trickle in steadily over the day, but lets you save up a few and use them all at once if you haven't ridden in a while — some real burstiness is fine, as long as your average pace over the day stays within bounds. A concurrency limiter isn't about tickets over time at all — it's the ride physically only having 3 cars, so however many people show up over the whole day, never more than 3 groups are ever mid-ride at the same instant.
Under the Hood
- The examples above define a single limiter shared by every caller of an endpoint — useful, but often too coarse: you usually want the limit applied per client, not one shared pool for the entire world.
AddPolicy(the more general registration method, alongside the algorithm-specific shortcuts shown above) accepts a partition key — commonly the client's IP address, or an authenticated user's ID (read from theClaimsPrincipalthe authentication lesson covered) — so each distinct client gets its own independent counter, rather than everyone sharing one global limit.- Under the hood, each algorithm's state (window counts, bucket token levels, in-flight counts) is tracked in memory by the middleware, per partition key, using precise, thread-safe counters — this is why the limiter itself adds only a small, predictable amount of overhead per request compared to the cost of whatever it's protecting.
Common Confusion
1. "Rate limiting is only about stopping attackers" — no, fairness and downstream protection matter just as much
It's tempting to think of rate limiting purely as a security feature against malicious traffic. As the hook illustrated, entirely well-intentioned clients can just as easily overwhelm a shared resource through a bug, an unexpectedly popular feature, or simple scale. Rate limiting protects the system's overall health and fairness, independent of anyone's intent.
2. "A rate limit over time and a concurrency limit are the same idea" — they measure genuinely different things
A time-based limit (fixed window, sliding window, token bucket) asks "how many requests happened in this period?" A concurrency limit asks "how many requests are actively in progress right now?" A client sending requests one-at-a-time, waiting for each to finish before sending the next, could never trip a concurrency limiter no matter how many total requests it sends over a day — but it could easily trip a strict fixed-window limit if it sends them fast enough within one window. These are answering different questions, and picking the wrong one for a given problem (protecting a limited connection pool vs. protecting against a chatty client) won't actually solve it.
Common Mistakes
Mistake 1 — Applying one global limit shared by every client
A single fixed-window policy with no partition key, meaning one aggressive client can exhaust the entire limit and lock out every other legitimate client sharing it.
Partition the limiter by client IP or authenticated user ID, so each caller gets its own independent budget.
Mistake 2 — Using Fixed Window where boundary bursts genuinely matter
Protecting a genuinely fragile downstream resource (a strict third-party rate limit) with Fixed Window, not accounting for the possibility of nearly double the configured rate landing right at a window boundary.
Use Sliding Window (or a sufficiently conservative Token Bucket configuration) when the boundary-burst behavior would actually cause a real problem for what's being protected.
Mistake 3 — Reaching for a time-based limiter to protect against simultaneous load
Using a Token Bucket policy to try to cap how many report generations can run at once, when the actual risk is overwhelming the reporting engine with too much concurrent work — a slow, spread-out trickle of requests could still pass a time-based limit while still overloading the engine if enough of them land at once.
Use the Concurrency Limiter specifically when the concern is simultaneous in-flight load on a resource, not total volume over a time window.
When Should I Use It?
- Use Fixed Window for simple, strict caps where the boundary-burst behavior is an acceptable trade-off for simplicity — a reasonable default for endpoints like login where "at most N per minute" is a fine approximation.
- Use Sliding Window when the boundary-burst problem genuinely matters — protecting a resource with a hard, strict external limit that a brief 2x spike would actually violate.
- Use Token Bucket as a general-purpose default for everyday API traffic, when tolerating short, normal bursts (a user quickly paging through results) while still holding to a fair long-run average is the goal.
- Use the Concurrency Limiter when the real concern is simultaneous load on a specific resource (a database connection pool, an expensive computation engine) rather than total request volume over time.
- Don't apply the same blanket rate limit to every endpoint — a public, cheap read endpoint and an expensive write or authentication endpoint have genuinely different, reasonable limits.
Mental Model
Sliding Window = a continuously moving window — fixes the boundary burst, costs more bookkeeping.
Token Bucket = tolerates real bursts, enforces a steady average over time.
Concurrency Limiter = caps simultaneous in-flight requests — a genuinely different axis than "over time."
Remember: Rate limiting protects the system from abuse, unfairness between clients, and overload of downstream resources — three real reasons, not just one.
Key Takeaway
- ASP.NET Core's built-in
Microsoft.AspNetCore.RateLimitingmiddleware (since .NET 7) caps traffic per named policy, applied to specific endpoints viaRequireRateLimiting(). - Rate limiting exists to protect against abuse, ensure fairness across clients sharing an API, and prevent downstream resources from being overwhelmed — even by well-intentioned traffic.
- Fixed Window is simple but allows a burst at window boundaries; Sliding Window smooths that out at extra bookkeeping cost.
- Token Bucket tolerates short bursts while enforcing a steady average rate — a common general-purpose default.
- The Concurrency Limiter caps simultaneous in-flight requests rather than volume over time — a genuinely different kind of limit, suited to protecting resources sensitive to simultaneous load.
Check Your Understanding
You've seen why APIs need rate limiting and how each of the four built-in algorithms handles the problem differently. Let's check your understanding.
1. A fixed window limiter allows 100 requests per 60-second window. What genuine weakness does this algorithm have?
Show answer
Correct: B
Why B is correct: Because the window resets entirely at fixed clock-aligned boundaries, a client can exploit both the tail end of one window and the start of the next, briefly seeing close to double the configured rate.
Why A is incorrect: Fixed window limiters, like all the built-in algorithms, are applied per named policy to specific endpoints via RequireRateLimiting().
Why C is incorrect: The built-in middleware tracks state in memory, not in a database, for its counters.
Why D is incorrect: Rate limiting middleware actively rejects requests over the limit (typically with 429), it doesn't just log and allow them through.
Reinforcement: The boundary-burst weakness is the specific, well-known trade-off Fixed Window makes for its simplicity — and it's exactly what Sliding Window exists to fix.
2. Which algorithm specifically limits how many requests can be actively processed at the same moment, rather than how many occur over a period of time?
Show answer
Correct: D
Why D is correct: The Concurrency Limiter caps in-flight, simultaneous requests — a request holds a slot from when it starts until it finishes, independent of how request volume is distributed over time.
Why A, B, and C are incorrect: Fixed Window, Sliding Window, and Token Bucket are all fundamentally time-based — they count requests over a period, which is a genuinely different measurement than "how many are in flight right now."
Reinforcement: Concurrency limiting answers "how much is happening at once," a distinct question from every time-window-based algorithm.
3. You're rate limiting a general-purpose API endpoint where you want to allow users to quickly page through several pages of results in a row (a natural short burst), while still enforcing a fair average request rate over time. Which algorithm best fits this need?
Show answer
Correct: B
Why B is correct: This is precisely the shape of problem Token Bucket is designed for — a full bucket allows a short burst of paging requests, and the steady token refill rate keeps the long-run average fair.
Why A is incorrect: Fixed Window can technically allow some burstiness too, but it's not designed for it deliberately — and it comes with the specific boundary-burst weakness rather than a controlled, intentional one.
Why C is incorrect: Paging through results one request at a time isn't primarily a concurrency concern (multiple requests in flight simultaneously) — it's a request-volume-over-time concern, which is what Token Bucket addresses.
Why D is incorrect: This is exactly what Token Bucket, a built-in algorithm, is designed to express — no custom solution is needed.
Reinforcement: "Allow some burst, enforce a steady average" is Token Bucket's defining characteristic — recognize that phrasing and reach for it directly.
4. Why is it a mistake to apply a single, unpartitioned rate limit policy shared by every client hitting an endpoint?
Show answer
Correct: B
Why B is correct: Without a partition key (like client IP or user ID), all callers share one counter — so a single high-traffic client can exhaust the whole limit and lock out everyone else, directly undermining the "fairness" goal rate limiting is meant to serve.
Why A is incorrect: Unpartitioned (single, shared) policies are valid configuration — the earlier Simple Example uses exactly one — they're just usually the wrong choice for multi-client scenarios.
Why C is incorrect: Partitioning by key is a general capability available across the rate limiting system, not specific to one algorithm.
Why D is incorrect: This is a real fairness problem, explicitly called out as a common mistake, not merely a performance concern.
Reinforcement: Partitioning by client is usually essential in real multi-tenant APIs — a shared limit with no partition key defeats the fairness goal that motivated rate limiting in the first place.
You now know how to protect your API — and everything behind it — from excessive traffic, whether it comes from an attacker, a bug, or simply more legitimate demand than one client should fairly consume.