The code lives in a public repo and is tested in CI.
This is part 3 of the series about my free, MIT-licensed .NET 10 Minimal API starter. In part 1 I covered the base template, and in part 2 I added per-user ownership of resources.
Today: protecting /register and /login from brute-force attempts with the rate limiter that ships with ASP.NET Core. No NuGet package needed.
What I wanted
- Limit only the auth endpoints, not the whole API.
- Limit per client IP, so one noisy client doesn't lock everyone out.
- Return a proper
429 Too Many Requestswith aRetry-Afterheader and a ProblemDetails body. - Make the limits configurable from
appsettings.json. - Keep the existing integration tests unaffected.
1. Options bound from configuration
public sealed class AuthRateLimitOptions { public const string Section = "RateLimiting:Auth"; public int PermitLimit { get; init; } = 5; // requests allowed... public int WindowSeconds { get; init; } = 60; // ...per window } This is a plain class that holds the two numbers. The defaults are 5 requests per 60 seconds, and anyone can override them in configuration.
2. The policy
public static class RateLimitingExtensions { public const string AuthPolicy = "auth"; public static IServiceCollection AddAuthRateLimiting( this IServiceCollection services, IConfiguration configuration) { var settings = configuration.GetSection(AuthRateLimitOptions.Section).Get<AuthRateLimitOptions>() ?? new AuthRateLimitOptions(); services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy(AuthPolicy, httpContext => RateLimitPartition.GetFixedWindowLimiter( httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = settings.PermitLimit, Window = TimeSpan.FromSeconds(settings.WindowSeconds), QueueLimit = 0 })); options.OnRejected = async (context, ct) => { if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture); } await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails { Status = StatusCodes.Status429TooManyRequests, Title = "Too many requests. Try again later." }, ct); }; }); return services; } } What each piece does:
-
AddPolicyregisters a named policy. Endpoints opt in by name, so everything else stays unlimited. -
RateLimitPartition.GetFixedWindowLimitercreates one independent counter for each partition key. Using the IP as the key gives you one bucket per client. -
QueueLimit = 0means that excess requests get a 429 right away instead of waiting. -
OnRejectedsets theRetry-Afterheader and writes a ProblemDetails response, so the error looks like every other error in the API.
3. Wiring it up
builder.Services.AddAuthRateLimiting(builder.Configuration); // ... app.UseHttpsRedirection(); app.UseRateLimiter(); // before authentication app.UseAuthentication(); app.UseAuthorization(); Then I opt the two endpoints in:
group.MapPost("/register", Register) .AddEndpointFilter<ValidationFilter<RegisterRequest>>() .RequireRateLimiting(RateLimitingExtensions.AuthPolicy); group.MapPost("/login", Login) .AddEndpointFilter<ValidationFilter<LoginRequest>>() .RequireRateLimiting(RateLimitingExtensions.AuthPolicy); UseRateLimiter() goes before UseAuthentication() so that abusive requests are rejected before any password hashing happens.
4. Testing it without breaking the other tests
Every integration test calls /register and /login, so a limit of 5 per minute would make the whole suite flaky. My ApiFactory (the shared WebApplicationFactory) raises the limit:
builder.UseSetting("RateLimiting:Auth:PermitLimit", "1000"); A dedicated factory then lowers it to 3 for the one test that checks the limiter itself:
builder.UseSetting("RateLimiting:Auth:PermitLimit", "3"); [Fact] public async Task Login_is_rate_limited_after_too_many_attempts() { var body = new { email = "nobody@test.com", password = "Wrong1234!" }; for (var i = 0; i < 3; i++) { var allowed = await _client.PostAsJsonAsync("/api/auth/login", body); Assert.Equal(HttpStatusCode.Unauthorized, allowed.StatusCode); } var blocked = await _client.PostAsJsonAsync("/api/auth/login", body); Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode); Assert.NotNull(blocked.Headers.RetryAfter); } The tests run in CI like everything else, and I also checked the 429 by hand with the .http file.
Limitations
This setup is intentionally simple, and you should know where it stops:
- Behind a reverse proxy (nginx, a cloud load balancer),
RemoteIpAddressis the proxy's IP unless you configureUseForwardedHeaders. Otherwise all your users share one bucket. - The counters live in memory, per instance. If you scale to several instances, each one counts separately. For that you'd need a distributed approach (e.g. Redis).
- It limits by IP, not by account. Someone spreading attempts across many IPs is not stopped. A per-account lockout is on my list.
Try it
Next up: a /health endpoint that checks the database, and refresh tokens. If you have feedback or want something covered, open a Discussion.
