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 Requests with a Retry-After header 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:

  • AddPolicy registers a named policy. Endpoints opt in by name, so everything else stays unlimited.
  • RateLimitPartition.GetFixedWindowLimiter creates one independent counter for each partition key. Using the IP as the key gives you one bucket per client.
  • QueueLimit = 0 means that excess requests get a 429 right away instead of waiting.
  • OnRejected sets the Retry-After header 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), RemoteIpAddress is the proxy's IP unless you configure UseForwardedHeaders. 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.