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.
Top comments (0)