Rate Limiting: Stopping Password Guessing and Runaway Clients – Web API | Lesson 92
Introduction
Since Lesson 85, a wrong password answers "User name or password is wrong." A person needs a few tries at most. A program could try thousands of passwords a minute, and the API would happily check every one.
Rate limiting caps how many requests a caller may make in a period. This lesson limits sign-in attempts, and gives every user a fair share of the API, using the rate limiter built into ASP.NET Core.
By the end of this lesson, you will be able to:
- Explain what rate limiting protects against.
- Compare the fixed window, sliding window, and token bucket limiters.
- Apply a limit to one endpoint, and a global limit to every request.
- Partition limits per client address or per user.
- Answer refused requests with 429 and
Retry-After.
Why this matters
Password guessing is the most common attack on any sign-in page. Slowing it to five tries a minute turns "minutes" into "years" for an attacker.
Limits also protect the school from honest mistakes. A buggy app that calls the API in a loop could slow the system for everyone during attendance time. A per-user limit stops one caller from taking everyone's share.
The concept
Think of the school canteen. Each student may take three snacks per break. When the bell rings for the next break, the count starts again.
Example: three kinds of limiter
| Limiter | How it counts | Good for |
|---|---|---|
| Fixed window | N requests per window; the count resets when the window ends | sign-in attempts: simple and strict |
| Sliding window | N requests in any window, counted in small segments | smoother limits without a reset moment |
| Token bucket | a bucket of N tokens; each request takes one; tokens refill at a steady rate | general traffic: short bursts allowed |
A fixed window has a weak spot at the edges: a caller could use five attempts at 9:00:59 and five more at 9:01:00. For sign-in that is still only ten guesses, which is fine. A sliding window removes that edge, at a small cost in memory.
Example: partitions
A limit applies per partition: a key that decides whose count a request uses.
| Partition key | Means | SMS use |
|---|---|---|
| client address | each computer or network gets its own count | sign-in attempts, before anyone is known |
| user id | each signed-in user gets their own count | the global per-user limit |
Partitioning by user matters in a school. Many teachers use the same school network, so they share one address. A per-address limit would let one busy teacher block the whole staff room.
Parents are the opposite case: each family usually has its own address at home, so the sign-in limit per address rarely affects anyone honest.
Example: 429 and Retry-After
A refused request gets 429 Too Many Requests. A Retry-After header tells the client how many seconds to
wait. Well-behaved apps wait that long instead of trying again at once, which would only be refused again.
Example: rate limiting or account lockout?
Another defence is lockout: after five wrong passwords, the account is blocked for a while. It stops guessing too, but it has a nasty side effect.
| Rate limit per address (the SMS) | Account lockout | |
|---|---|---|
| Who is slowed down | the address that made the attempts | the account, wherever the real user is |
| Can an attacker block a teacher? | no: Anitha signs in from her own address | yes: five wrong guesses lock Anitha out |
| Guessing many accounts from one address | slowed down | each account allows five more guesses |
Many systems combine both, with a long lockout only after many failures and an alert to the user. The SMS starts with the rate limit, which cannot be used to lock people out.
Example: in the app, or in front of it?
Limits can also live in a gateway or reverse proxy in front of the API, which can refuse floods before they cost the API anything. A gateway only sees addresses and URLs, though. It does not know which user a token belongs to.
So large systems often use both: a coarse per-address limit at the edge, and the API's own per-user limits, like the ones in this lesson.
Apply it to the SMS
Step 1: the limits, in configuration
"RateLimits": {
"LoginPerMinute": 5,
"RequestsPerMinute": 300
}
Like every section since Lesson 70, it is bound to a class and checked when the API starts.
Example: choosing the numbers
Start from how people really use the system. A teacher taking attendance opens the register, saves it, and perhaps corrects one line: a handful of requests a minute. The busiest screen, the office dashboard, refreshes far less than once a second.
So 300 requests a minute per user is generous for any person, and still stops a program stuck in a loop. Five sign-ins a minute covers typing mistakes. Watch the rejection logs after going live, and adjust the numbers in configuration, not in code.
One case needs care. If all staff sign in from the school's single network address at 8:00, five sign-ins a
minute for that address is too few: the sixth teacher would wait. Such a school raises LoginPerMinute for its
deployment and adds a per-account protection, such as a short lockout with an alert (the table above), so
guessing one account stays slow.
Step 2: two limiters and one answer
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using SchoolApp.Api.Models;
namespace SchoolApp.Api.Security;
// Lesson 92: the "RateLimits" section of configuration.
public class RateLimitOptions
{
public const string SectionName = "RateLimits";
// Sign-in attempts per client address per minute: enough for typing mistakes, too few for guessing.
[Range(1, 10_000)]
public int LoginPerMinute { get; set; } = 5;
// Requests per user (or per address, before signing in) per minute, with bursts up to the same number.
[Range(1, 100_000)]
public int RequestsPerMinute { get; set; } = 300;
}
// Lesson 92: the two limiters, and what a refused request receives.
public static class RateLimits
{
public const string LoginPolicy = "login";
public static void Configure(RateLimiterOptions limiter, RateLimitOptions limits)
{
// For sign-in: a fixed window per client address. The sixth attempt in a minute waits for the next one.
limiter.AddPolicy(LoginPolicy, http => RateLimitPartition.GetFixedWindowLimiter(
ClientAddress(http),
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = limits.LoginPerMinute,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0 // refuse at once instead of making the caller wait
}));
// For every request: a token bucket per user. The bucket holds RequestsPerMinute tokens, each request takes
// one, and a sixth of them come back every 10 seconds. Short bursts are fine; a runaway loop is not.
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(http => RateLimitPartition.GetTokenBucketLimiter(
http.User.FindFirstValue("sub") is string userId ? $"user:{userId}" : $"ip:{ClientAddress(http)}",
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = limits.RequestsPerMinute,
TokensPerPeriod = Math.Max(1, limits.RequestsPerMinute / 6),
ReplenishmentPeriod = TimeSpan.FromSeconds(10),
QueueLimit = 0
}));
// A refused request: 429 in the SMS response shape, with Retry-After when the limiter knows it.
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
await context.HttpContext.Response.WriteAsJsonAsync(
ApiResponse.Fail("Too many requests. Please wait a moment and try again."), cancellationToken);
};
}
private static string ClientAddress(HttpContext http) => http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
loginis a named policy: a fixed window per client address, used only where an endpoint asks for it.GlobalLimiterapplies to every request: a token bucket per user, or per address before signing in.OnRejectedwrites the SMS response shape (Lesson 80) and copiesRetry-Afterfrom the limiter.
The two limiters work together: a sign-in request must pass both the global bucket and the login window.
Every other request passes only the global bucket.
QueueLimit = 0 refuses extra requests at once. A queue would hold them until a slot frees up, which makes sense
for background work but not for a person waiting at a screen.
Step 3: register it, and mark the sign-in endpoint
builder.Services.AddRateLimiter(limiter => RateLimits.Configure(limiter, rateLimits));
app.UseAuthentication();
app.UseUserLogScope();
app.UseRateLimiter(); // Lesson 92: after authentication, so limits can be counted per user
[HttpPost("login")]
[AllowAnonymous]
[AllowWhenReadOnly]
[EnableRateLimiting(RateLimits.LoginPolicy)] // Lesson 92: 5 attempts per minute per address
UseRateLimiter comes after UseAuthentication. Before it, User would be empty and every request would count
against its address instead of its user.
Step 4: six guesses, then the right password
Six wrong passwords for anitha.rao in a row, then the right one, all within the same minute:
POST /api/auth/login (guess1 ... guess5) 401 User name or password is wrong. (five times)
POST /api/auth/login (guess6) 429 Retry-After: 60
{"status":"error","message":"Too many requests. Please wait a moment and try again.","traceId":"00-cbad…"}
POST /api/auth/login (Demo@2026) 429 Retry-After: 60
The right password was refused too. That is the point: during the minute, the limiter does not check passwords at all, so a guesser learns nothing. Anitha waits a minute and signs in.
Look at what the attacker got from these seven requests: five "wrong" answers and two refusals, nothing else. At five guesses a minute, trying the 10,000 most common passwords would take about 33 hours from one address, and every rejected attempt is in the log (Lesson 90) for the team to see.
The global limit was not reached here. Seven requests are far below 300, and sign-in requests count against the address, because nobody is signed in yet.
Step 5: tests with small limits
In tests, every request comes from the same address, so ApiFactory raises both limits. Without that, the many
sign-ins across the test classes would start failing with 429. RateLimitTests then builds its own server
with small limits:
Show tests/SchoolApp.Tests/Api/RateLimitTests.cs
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using SchoolApp.Api.Security;
namespace SchoolApp.Tests.Api;
// Lesson 92: the sign-in limit and the per-user limit, on a server with small limits of its own.
[Collection("Api")]
public class RateLimitTests
{
private readonly ApiFactory _factory;
public RateLimitTests(ApiFactory factory)
{
_factory = factory;
}
private HttpClient ClientWithLimits(int loginPerMinute, int requestsPerMinute) =>
_factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("RateLimits:LoginPerMinute", loginPerMinute.ToString());
builder.UseSetting("RateLimits:RequestsPerMinute", requestsPerMinute.ToString());
}).CreateClient();
[Fact]
public async Task FourthSignInAttempt_InAMinute_Gets429()
{
HttpClient client = ClientWithLimits(loginPerMinute: 3, requestsPerMinute: 1000);
var wrong = new { userName = ApiFactory.UserName, password = "wrong-password" };
HttpStatusCode[] first3 = new HttpStatusCode[3];
for (int i = 0; i < 3; i++)
{
first3[i] = (await client.PostAsJsonAsync("/api/auth/login", wrong)).StatusCode;
}
HttpResponseMessage fourth = await client.PostAsJsonAsync("/api/auth/login", wrong);
Assert.All(first3, status => Assert.Equal(HttpStatusCode.Unauthorized, status));
Assert.Equal(HttpStatusCode.TooManyRequests, fourth.StatusCode);
Assert.NotNull(fourth.Headers.RetryAfter?.Delta);
JsonElement body = await fourth.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("error", body.GetProperty("status").GetString());
}
[Fact]
public async Task OneUser_OverTheLimit_Gets429_ForEveryEndpoint()
{
HttpClient client = ClientWithLimits(loginPerMinute: 100, requestsPerMinute: 6);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
await ApiFactory.GetTokenAsync(client, ApiFactory.UserNameFor(Roles.Principal)));
List<HttpStatusCode> statuses = [];
for (int i = 0; i < 7; i++)
{
statuses.Add((await client.GetAsync("/api/auth/me")).StatusCode);
}
Assert.Equal(Enumerable.Repeat(HttpStatusCode.OK, 6), statuses.Take(6)); // the bucket held 6 tokens
Assert.Equal(HttpStatusCode.TooManyRequests, statuses[6]);
}
}
With three sign-ins per minute, the fourth wrong attempt gets 429 with Retry-After. With six requests per
minute, a user's seventh request gets 429, for any endpoint. The suite has 141 tests, all passing.
Common mistakes
- No limit on sign-in. Passwords can be guessed at full speed. Fix: a strict limit on the sign-in endpoint.
- Limiting only by address. A whole school shares one address and blocks itself. Fix: per user once signed in.
UseRateLimiterbeforeUseAuthentication. Every request counts as anonymous. Fix: after authentication.- 429 without
Retry-After. Clients retry at once, and get refused again. Fix: send it whenever the limiter knows it. - Forgetting tests share one address. The suite fails randomly with 429. Fix: raise the limits in the test setup, and test limits on purpose with small values.
In real projects
A school portal noticed hundreds of failed sign-ins every night, always for the principal's and the accountant's accounts. Someone was trying common passwords, one after another.
The portal had no limit, so each night brought thousands of tries. One weak password would have been found eventually.
We added a five-per-minute limit on sign-in and an alert on rejected attempts. The attacks did not stop at once, but each night now gave the attacker a few hundred guesses instead of thousands. The alert also pushed the team to enforce stronger passwords.
Best practices
- Put a strict limit on sign-in and other sensitive endpoints, such as password reset.
- Give every signed-in user a fair share with a global per-user limit.
- Keep limits in configuration, and check them at startup.
- Answer 429 with
Retry-After, in the same shape as other errors. - Log and alert on rejections: they often mean an attack or a broken client.
Recap
- Rate limiting caps requests per partition in a period.
- Sign-in uses a fixed window: five attempts per minute per address.
- Every request also uses a token bucket per user: 300 a minute, with bursts.
- The sixth guess, and even the right password, got 429 with
Retry-After: 60. - Tests raise the limits, then check them with small values: 141 tests pass.
Homework
Library module task: Limit book reservations in a single-file library API with a sliding window. Allow 3
reservations per student in any 60 seconds, in 6 segments. Partition by the X-Student-Id header, and answer
the 4th reservation with 429.
A correct result looks like:
POST /api/reservations X-Student-Id: 3 (three times) 200 {"message":"9780143039655 reserved for student 3."}
POST /api/reservations X-Student-Id: 3 (fourth) 429 {"status":"error","message":"Too many reservations. Try again later."}
POST /api/reservations X-Student-Id: 7 200 {"message":"9780141325293 reserved for student 7."}
Show the solution
// Library homework, Lesson 92: a sliding-window limit per student. Each student may place 3 book reservations
// in any 60 seconds; the 4th gets 429. (A sliding window does not report when a slot frees up, so unlike the
// SMS's fixed window there is no Retry-After header.) The student id comes from the X-Student-Id header here,
// to keep the file short; the SMS takes it from the signed token (Lesson 85).
// Run with: dotnet run library-assignment/092-library-rate-limit.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddRateLimiter(limiter =>
{
// A sliding window of 60 seconds in 6 segments of 10 seconds: old requests drop out bit by bit,
// instead of all at once when a fixed window ends.
limiter.AddPolicy("reservations", http => RateLimitPartition.GetSlidingWindowLimiter(
http.Request.Headers["X-Student-Id"].ToString(),
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 3,
Window = TimeSpan.FromSeconds(60),
SegmentsPerWindow = 6,
QueueLimit = 0
}));
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
}
await context.HttpContext.Response.WriteAsJsonAsync(new { status = "error", message = "Too many reservations. Try again later." },
cancellationToken);
};
});
WebApplication app = builder.Build();
app.UseRateLimiter();
app.MapControllers();
app.Run("http://localhost:5092");
public record ReservationRequest(string Isbn);
[ApiController]
[Route("api/reservations")]
public class ReservationsController : ControllerBase
{
[HttpPost]
[EnableRateLimiting("reservations")]
public IActionResult Reserve(ReservationRequest request, [FromHeader(Name = "X-Student-Id")] int studentId) =>
Ok(new { message = $"{request.Isbn} reserved for student {studentId}." });
}
The sliding window gave no Retry-After value when we ran it, so the 429 has no such header, unlike the SMS's
fixed window. Student 7 has a partition of their own, so student 3's limit does not affect them.
Quiz
What status code does a rate-limited request receive?
Show answer
Answer: C. 429 Too Many Requests With Retry-After when the limiter knows how long to wait.
Why was the right password refused after five wrong ones?
Show answer
Answer: B. During the window the limiter refuses every sign-in from that address, so a guesser learns nothing After the minute, sign-in works again.
Why is the global limit partitioned by user, not by address?
Show answer
Answer: B. Many teachers share one school network address; one busy teacher would block all of them Before signing in, the address is the only key available.
Which limiter allows short bursts but keeps a steady average?
Show answer
Answer: B. Token bucket Tokens refill at a steady rate; a full bucket allows a burst.
Why must UseRateLimiter come after UseAuthentication?
Show answer
Answer: B. Otherwise User is empty and every request counts as anonymous Per-user limits need the user.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Security interview questions.
Fresher (0-2 yrs)
F What is rate limiting, and what does it protect against? concept
Capping how many requests a caller may make in a period. It slows password guessing on sign-in, stops one client (or a buggy app in a loop) from taking everyone's share, and limits the damage of floods. The SMS allows 5 sign-ins per minute per address and 300 requests per minute per user.
F What should an API return when a request is rate limited? concept
429 Too Many Requests, with a Retry-After header saying how many seconds to wait when the limiter knows it, and a body in the API's usual error shape. Well-behaved clients wait instead of retrying at once.
F Compare the fixed window, sliding window, and token bucket limiters. concept
Fixed window: N requests per window, reset when it ends; simple, but bursts can cluster at the edge. Sliding window: N requests in any window, counted in segments; smoother. Token bucket: a bucket of N tokens refilled at a steady rate; allows short bursts with a steady average. The SMS uses a fixed window for sign-in and a token bucket for general traffic.
Mid (3-5 yrs)
M What is a partition in ASP.NET Core rate limiting, and how do you choose the key? design
A partition is whose count a request uses, chosen by a key computed from the request. Before sign-in the only key is the client address; once signed in, the user id is fairer, because many users can share one address. The SMS uses "user:{sub}" when signed in and "ip:{address}" otherwise.
M Where must app.UseRateLimiter() go in the pipeline, and why? coding
After UseAuthentication (and after routing, which minimal hosting adds early), so the limiter can see the signed-in user for per-user limits and the endpoint's [EnableRateLimiting] policy. Placed before authentication, every request would count as anonymous.
M Why are rate-limit tests often flaky, and how do you avoid it? scenario
In tests every request comes from the same address and the suite makes many sign-ins, so a real limit is hit at random. Raise the limits in the shared test setup, and test the limiter on purpose with a separate server configured with small limits, as the SMS's RateLimitTests does.
Senior (6-9 yrs)
S Should you protect sign-in with account lockout or with rate limiting? design
Lockout blocks the account after N failures, which also lets an attacker lock a real user out on purpose. A rate limit per address slows the attacker's address without touching the user, but a guesser with many addresses gets N guesses each. Combine them: a per-address limit first, a softer per-account protection (delays or a long lockout only after many failures), and alerts on repeated failures.
How to answer: Compare who each defence slows down, how each can be abused, and propose a layered design.
S At 8:00 every teacher signs in from the school's one network address and many get 429. What do you change? scenario
The sign-in limit is per address, and the whole staff room shares one. Raise the per-address sign-in limit for that school's deployment (it is configuration), and keep guessing slow with a per-account protection, such as a short lockout or growing delays per user name, plus alerts. Check the rejection logs to confirm the cause first.
How to answer: Diagnose from the logs, explain why the partition key causes it, and give a fix that keeps the security goal.
Architect / Lead (10+ yrs)
A The API runs on several servers behind a load balancer. How do you make rate limits correct across all of them? design
The built-in limiter counts in each server's memory, so N servers allow roughly N times the limit. Options: a gateway or reverse proxy that limits in one place before the servers; a shared store (such as Redis) that all servers count in; or dividing each server's limit by the number of servers as an approximation. Keep per-user limits in the API, where the user is known, and coarse per-address limits at the edge.
How to answer: Explain why in-memory counts multiply, compare edge limiting, shared counters, and approximations, and say which limits belong where.
Resources
- Code for this lesson: git tag
lesson-92-rate-limiting(code repository coming soon) - Files:
src/SchoolApp.Api/Security/RateLimits.cs,src/SchoolApp.Api/Program.cs,src/SchoolApp.Api/Controllers/AuthController.cs,tests/SchoolApp.Tests/Api/RateLimitTests.cs,library-assignment/092-library-rate-limit.cs - Video: coming soon
What's next
The API is protected from too many requests. Next, it learns to change without breaking the apps that already use it: API versioning. Lesson 93: API versioning
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.