15 minlesson

Health Checks, CORS & Rate Limiting

Health Checks, CORS & Rate Limiting

In this presentation, you will add health checks that verify database connectivity, configure CORS for frontend consumption, set up rate limiting to protect the API from abuse, and add request/response logging middleware.

Health Checks in ASP.NET Core

The health check framework is built into ASP.NET Core. It provides a standard way for load balancers, orchestrators, and monitoring systems to verify that your service is operational.

Basic Health Check Setup

csharp
1builder.Services.AddHealthChecks();
2
3app.MapHealthChecks("/health");

A GET /health request returns one of three statuses:

StatusHTTP CodeMeaning
Healthy200Service is functioning normally
Degraded200Service works but something is suboptimal
Unhealthy503Service cannot handle requests

Custom Database Health Check

For the parcel tracking API, the most critical dependency is the database. Write a custom health check that verifies connectivity:

csharp
1public class DatabaseHealthCheck : IHealthCheck
2{
3 private readonly ParcelTrackingDbContext _dbContext;
4
5 public DatabaseHealthCheck(ParcelTrackingDbContext dbContext)
6 {
7 _dbContext = dbContext;
8 }
9
10 public async Task<HealthCheckResult> CheckHealthAsync(
11 HealthCheckContext context,
12 CancellationToken cancellationToken = default)
13 {
14 try
15 {
16 await _dbContext.Database.CanConnectAsync(cancellationToken);
17 return HealthCheckResult.Healthy("Database connection is active.");
18 }
19 catch (Exception ex)
20 {
21 return HealthCheckResult.Unhealthy(
22 "Database connection failed.",
23 exception: ex);
24 }
25 }
26}

Register it:

csharp
1builder.Services.AddHealthChecks()
2 .AddCheck<DatabaseHealthCheck>("database");

EF Core Health Check Package

Alternatively, the AspNetCore.HealthChecks.NpgSql or Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore package provides a ready-made EF Core check:

csharp
1builder.Services.AddHealthChecks()
2 .AddDbContextCheck<ParcelTrackingDbContext>("database");

This calls CanConnectAsync on the DbContext and reports the result.

Structured Health Check Responses

The default health endpoint returns a plain text status. For richer output, configure a custom response writer:

csharp
1app.MapHealthChecks("/health", new HealthCheckOptions
2{
3 ResponseWriter = async (context, report) =>
4 {
5 context.Response.ContentType = "application/json";
6
7 var response = new
8 {
9 status = report.Status.ToString(),
10 checks = report.Entries.Select(e => new
11 {
12 name = e.Key,
13 status = e.Value.Status.ToString(),
14 description = e.Value.Description,
15 duration = e.Value.Duration.TotalMilliseconds
16 }),
17 totalDuration = report.TotalDuration.TotalMilliseconds
18 };
19
20 await context.Response.WriteAsJsonAsync(response);
21 }
22});

This returns a JSON response:

json
1{
2 "status": "Healthy",
3 "checks": [
4 {
5 "name": "database",
6 "status": "Healthy",
7 "description": "Database connection is active.",
8 "duration": 12.5
9 }
10 ],
11 "totalDuration": 15.2
12}

Liveness and Readiness Endpoints

Kubernetes distinguishes between liveness (is the process alive?) and readiness (can it accept traffic?). Separate these by filtering which checks run at each path:

csharp
1app.MapHealthChecks("/health/live", new HealthCheckOptions
2{
3 Predicate = _ => false // No checks, just confirms the process is running
4});
5
6app.MapHealthChecks("/health/ready", new HealthCheckOptions
7{
8 Predicate = check => check.Tags.Contains("ready")
9});

Register the database check with the "ready" tag:

csharp
1builder.Services.AddHealthChecks()
2 .AddCheck<DatabaseHealthCheck>("database", tags: ["ready"]);

Now /health/live always returns 200 (the process is running), while /health/ready returns 503 if the database is down.

CORS Configuration

When a frontend application at https://tracking.example.com calls your API at https://api.tracking.example.com, the browser blocks the request unless the API sends appropriate CORS headers.

Defining a CORS Policy

csharp
1builder.Services.AddCors(options =>
2{
3 options.AddPolicy("Frontend", policy =>
4 {
5 policy.WithOrigins(
6 "https://tracking.example.com",
7 "http://localhost:3000")
8 .WithMethods("GET", "POST", "PUT", "DELETE")
9 .WithHeaders("Content-Type", "Authorization")
10 .WithExposedHeaders("X-Pagination", "X-Request-Id");
11 });
12});

Applying CORS

Apply the policy globally in the middleware pipeline:

csharp
1app.UseCors("Frontend");

Or apply it per controller or per endpoint:

csharp
1[EnableCors("Frontend")]
2[ApiController]
3[Route("api/v1/parcels")]
4public class ParcelsController : ControllerBase
5{
6 // ...
7}

CORS Options Explained

MethodPurpose
WithOriginsDomains allowed to call the API
WithMethodsHTTP methods the client can use
WithHeadersRequest headers the client can send
WithExposedHeadersResponse headers the client can read
AllowCredentialsAllow cookies and auth headers (cannot combine with AllowAnyOrigin)

Development vs. Production CORS

In development, you might allow any origin for convenience. In production, always whitelist specific origins:

csharp
1if (builder.Environment.IsDevelopment())
2{
3 builder.Services.AddCors(options =>
4 {
5 options.AddPolicy("Frontend", policy =>
6 policy.AllowAnyOrigin()
7 .AllowAnyMethod()
8 .AllowAnyHeader());
9 });
10}
11else
12{
13 builder.Services.AddCors(options =>
14 {
15 options.AddPolicy("Frontend", policy =>
16 policy.WithOrigins("https://tracking.example.com")
17 .WithMethods("GET", "POST", "PUT", "DELETE")
18 .WithHeaders("Content-Type", "Authorization"));
19 });
20}

Rate Limiting

ASP.NET Core 7+ includes the Microsoft.AspNetCore.RateLimiting middleware. It provides several algorithms out of the box.

Fixed Window Rate Limiter

A fixed window limiter counts requests in a fixed time window and rejects any that exceed the limit:

csharp
1builder.Services.AddRateLimiter(options =>
2{
3 options.AddFixedWindowLimiter("PerClient", limiterOptions =>
4 {
5 limiterOptions.PermitLimit = 100;
6 limiterOptions.Window = TimeSpan.FromMinutes(1);
7 limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
8 limiterOptions.QueueLimit = 0;
9 });
10
11 options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
12});

Apply rate limiting to all endpoints:

csharp
1app.UseRateLimiter();

Then mark controllers or endpoints with the policy:

csharp
1[EnableRateLimiting("PerClient")]
2[ApiController]
3[Route("api/v1/parcels")]
4public class ParcelsController : ControllerBase
5{
6 // ...
7}

Or apply it globally:

csharp
1app.MapControllers().RequireRateLimiting("PerClient");

Partitioning by Client IP

Rate limits should apply per client, not globally. Partition by the client's IP address:

csharp
1builder.Services.AddRateLimiter(options =>
2{
3 options.AddPolicy("PerClient", context =>
4 RateLimitPartition.GetFixedWindowLimiter(
5 partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
6 factory: _ => new FixedWindowRateLimiterOptions
7 {
8 PermitLimit = 100,
9 Window = TimeSpan.FromMinutes(1)
10 }));
11
12 options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
13
14 options.OnRejected = async (context, ct) =>
15 {
16 context.HttpContext.Response.ContentType = "application/json";
17 await context.HttpContext.Response.WriteAsJsonAsync(new
18 {
19 title = "Too Many Requests",
20 status = 429,
21 detail = "Rate limit exceeded. Try again later."
22 }, ct);
23 };
24});

Rate Limit Response Headers

Clients benefit from knowing their remaining quota. The middleware automatically adds headers when configured:

1X-RateLimit-Limit: 100
2X-RateLimit-Remaining: 42
3X-RateLimit-Reset: 1708012800

These headers tell the client how many requests remain in the current window and when the window resets.

Excluding Health Checks from Rate Limiting

Health check endpoints should not be rate limited because monitoring tools poll them frequently:

csharp
1app.MapHealthChecks("/health")
2 .DisableRateLimiting();

Request/Response Logging Middleware

Structured logging of HTTP traffic helps diagnose issues and provides an audit trail.

Built-in HTTP Logging

ASP.NET Core provides UseHttpLogging for basic request/response logging:

csharp
1builder.Services.AddHttpLogging(options =>
2{
3 options.LoggingFields = HttpLoggingFields.RequestMethod
4 | HttpLoggingFields.RequestPath
5 | HttpLoggingFields.ResponseStatusCode
6 | HttpLoggingFields.Duration;
7 options.CombineLogs = true;
8});
9
10app.UseHttpLogging();

Custom Logging Middleware

For more control, write a custom middleware that logs request and response details:

csharp
1public class RequestLoggingMiddleware
2{
3 private readonly RequestDelegate _next;
4 private readonly ILogger<RequestLoggingMiddleware> _logger;
5
6 public RequestLoggingMiddleware(
7 RequestDelegate next,
8 ILogger<RequestLoggingMiddleware> logger)
9 {
10 _next = next;
11 _logger = logger;
12 }
13
14 public async Task InvokeAsync(HttpContext context)
15 {
16 var requestId = Guid.NewGuid().ToString("N")[..8];
17 context.Response.Headers["X-Request-Id"] = requestId;
18
19 var stopwatch = Stopwatch.StartNew();
20
21 try
22 {
23 await _next(context);
24 }
25 finally
26 {
27 stopwatch.Stop();
28
29 _logger.LogInformation(
30 "HTTP {Method} {Path} responded {StatusCode} in {Elapsed}ms [RequestId: {RequestId}]",
31 context.Request.Method,
32 context.Request.Path,
33 context.Response.StatusCode,
34 stopwatch.ElapsedMilliseconds,
35 requestId);
36 }
37 }
38}

Register it early in the pipeline:

csharp
1app.UseMiddleware<RequestLoggingMiddleware>();

What to Log and What Not to Log

Log these:

  • HTTP method, path, and query string
  • Response status code and elapsed time
  • Request ID / correlation ID
  • Client IP (for debugging, with privacy considerations)

Never log these:

  • Authorization headers or tokens
  • Request bodies containing passwords or PII
  • Full response bodies (too verbose and may contain sensitive data)

Middleware Pipeline Order

When combining all production features in Program.cs, the middleware registration order is critical:

Request → HttpLogging → CORS → RateLimiter → Auth → Routing → Endpoint
csharp
1// Middleware pipeline (order matters)
2app.UseHttpLogging();
3app.UseCors("Frontend");
4app.UseRateLimiter();
5app.UseAuthorization();
6
7app.MapHealthChecks("/health/live", new HealthCheckOptions
8{
9 Predicate = _ => false
10});
11
12app.MapHealthChecks("/health/ready", new HealthCheckOptions
13{
14 Predicate = check => check.Tags.Contains("ready")
15}).DisableRateLimiting();
16
17app.MapControllers().RequireRateLimiting("PerClient");
  1. HttpLogging runs first to capture every request, even rejected ones.
  2. CORS runs before routing so preflight requests are handled early.
  3. RateLimiter runs before authorization to reject excess requests cheaply.
  4. Authorization runs after rate limiting but before the endpoint.
  5. Routing dispatches to the correct controller action.

Getting this order wrong leads to subtle bugs. For example, placing CORS after authorization means preflight OPTIONS requests fail because they carry no auth token.

Key Takeaways

  1. Health checks use AddHealthChecks and MapHealthChecks to expose liveness and readiness endpoints
  2. Database health checks verify connectivity with CanConnectAsync or AddDbContextCheck
  3. CORS policies whitelist specific origins, methods, and headers for browser-based clients
  4. Rate limiting with AddRateLimiter and GetFixedWindowLimiter protects against request floods
  5. Partition rate limits by client IP so one client cannot exhaust the quota for everyone
  6. Request logging middleware captures method, path, status, and duration for diagnostics
  7. Middleware order matters: logging first, then CORS, then rate limiting, then auth