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
csharp1builder.Services.AddHealthChecks();23app.MapHealthChecks("/health");
A GET /health request returns one of three statuses:
| Status | HTTP Code | Meaning |
|---|---|---|
| Healthy | 200 | Service is functioning normally |
| Degraded | 200 | Service works but something is suboptimal |
| Unhealthy | 503 | Service 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:
csharp1public class DatabaseHealthCheck : IHealthCheck2{3 private readonly ParcelTrackingDbContext _dbContext;45 public DatabaseHealthCheck(ParcelTrackingDbContext dbContext)6 {7 _dbContext = dbContext;8 }910 public async Task<HealthCheckResult> CheckHealthAsync(11 HealthCheckContext context,12 CancellationToken cancellationToken = default)13 {14 try15 {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:
csharp1builder.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:
csharp1builder.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:
csharp1app.MapHealthChecks("/health", new HealthCheckOptions2{3 ResponseWriter = async (context, report) =>4 {5 context.Response.ContentType = "application/json";67 var response = new8 {9 status = report.Status.ToString(),10 checks = report.Entries.Select(e => new11 {12 name = e.Key,13 status = e.Value.Status.ToString(),14 description = e.Value.Description,15 duration = e.Value.Duration.TotalMilliseconds16 }),17 totalDuration = report.TotalDuration.TotalMilliseconds18 };1920 await context.Response.WriteAsJsonAsync(response);21 }22});
This returns a JSON response:
json1{2 "status": "Healthy",3 "checks": [4 {5 "name": "database",6 "status": "Healthy",7 "description": "Database connection is active.",8 "duration": 12.59 }10 ],11 "totalDuration": 15.212}
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:
csharp1app.MapHealthChecks("/health/live", new HealthCheckOptions2{3 Predicate = _ => false // No checks, just confirms the process is running4});56app.MapHealthChecks("/health/ready", new HealthCheckOptions7{8 Predicate = check => check.Tags.Contains("ready")9});
Register the database check with the "ready" tag:
csharp1builder.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
csharp1builder.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:
csharp1app.UseCors("Frontend");
Or apply it per controller or per endpoint:
csharp1[EnableCors("Frontend")]2[ApiController]3[Route("api/v1/parcels")]4public class ParcelsController : ControllerBase5{6 // ...7}
CORS Options Explained
| Method | Purpose |
|---|---|
WithOrigins | Domains allowed to call the API |
WithMethods | HTTP methods the client can use |
WithHeaders | Request headers the client can send |
WithExposedHeaders | Response headers the client can read |
AllowCredentials | Allow 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:
csharp1if (builder.Environment.IsDevelopment())2{3 builder.Services.AddCors(options =>4 {5 options.AddPolicy("Frontend", policy =>6 policy.AllowAnyOrigin()7 .AllowAnyMethod()8 .AllowAnyHeader());9 });10}11else12{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:
csharp1builder.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 });1011 options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;12});
Apply rate limiting to all endpoints:
csharp1app.UseRateLimiter();
Then mark controllers or endpoints with the policy:
csharp1[EnableRateLimiting("PerClient")]2[ApiController]3[Route("api/v1/parcels")]4public class ParcelsController : ControllerBase5{6 // ...7}
Or apply it globally:
csharp1app.MapControllers().RequireRateLimiting("PerClient");
Partitioning by Client IP
Rate limits should apply per client, not globally. Partition by the client's IP address:
csharp1builder.Services.AddRateLimiter(options =>2{3 options.AddPolicy("PerClient", context =>4 RateLimitPartition.GetFixedWindowLimiter(5 partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",6 factory: _ => new FixedWindowRateLimiterOptions7 {8 PermitLimit = 100,9 Window = TimeSpan.FromMinutes(1)10 }));1112 options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;1314 options.OnRejected = async (context, ct) =>15 {16 context.HttpContext.Response.ContentType = "application/json";17 await context.HttpContext.Response.WriteAsJsonAsync(new18 {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: 1002X-RateLimit-Remaining: 423X-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:
csharp1app.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:
csharp1builder.Services.AddHttpLogging(options =>2{3 options.LoggingFields = HttpLoggingFields.RequestMethod4 | HttpLoggingFields.RequestPath5 | HttpLoggingFields.ResponseStatusCode6 | HttpLoggingFields.Duration;7 options.CombineLogs = true;8});910app.UseHttpLogging();
Custom Logging Middleware
For more control, write a custom middleware that logs request and response details:
csharp1public class RequestLoggingMiddleware2{3 private readonly RequestDelegate _next;4 private readonly ILogger<RequestLoggingMiddleware> _logger;56 public RequestLoggingMiddleware(7 RequestDelegate next,8 ILogger<RequestLoggingMiddleware> logger)9 {10 _next = next;11 _logger = logger;12 }1314 public async Task InvokeAsync(HttpContext context)15 {16 var requestId = Guid.NewGuid().ToString("N")[..8];17 context.Response.Headers["X-Request-Id"] = requestId;1819 var stopwatch = Stopwatch.StartNew();2021 try22 {23 await _next(context);24 }25 finally26 {27 stopwatch.Stop();2829 _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:
csharp1app.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
csharp1// Middleware pipeline (order matters)2app.UseHttpLogging();3app.UseCors("Frontend");4app.UseRateLimiter();5app.UseAuthorization();67app.MapHealthChecks("/health/live", new HealthCheckOptions8{9 Predicate = _ => false10});1112app.MapHealthChecks("/health/ready", new HealthCheckOptions13{14 Predicate = check => check.Tags.Contains("ready")15}).DisableRateLimiting();1617app.MapControllers().RequireRateLimiting("PerClient");
- HttpLogging runs first to capture every request, even rejected ones.
- CORS runs before routing so preflight requests are handled early.
- RateLimiter runs before authorization to reject excess requests cheaply.
- Authorization runs after rate limiting but before the endpoint.
- 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
- Health checks use
AddHealthChecksandMapHealthChecksto expose liveness and readiness endpoints - Database health checks verify connectivity with
CanConnectAsyncorAddDbContextCheck - CORS policies whitelist specific origins, methods, and headers for browser-based clients
- Rate limiting with
AddRateLimiterandGetFixedWindowLimiterprotects against request floods - Partition rate limits by client IP so one client cannot exhaust the quota for everyone
- Request logging middleware captures method, path, status, and duration for diagnostics
- Middleware order matters: logging first, then CORS, then rate limiting, then auth