DEV Community

Fmaes
Fmaes

Posted on AI-assisted

Rate limiting login and registration in a .NET 10 Minimal API (per IP, no extra packages)

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
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)