16 Security Middlewares: Building a Hardened .NET API Pipeline
When I tell other developers that our .NET API has 16 security middlewares, the first reaction is always "isn't that overkill?" Then I tell them we're processing financial transactions for a regulated application, and the reaction shifts to "only 16?" Here's a detailed breakdown of every middleware in our pipeline, why it's there, and how they work together to create defense in depth.
The Pipeline Order Matters
Middleware execution order in ASP.NET Core is critical. A request flows through middlewares top to bottom, and the response flows back bottom to top. Getting the order wrong doesn't just break functionality — it creates security holes.
csharp// Program.cs — The complete security pipeline
public void Configure(IApplicationBuilder app)
{
// 1. Request ID
app.UseMiddleware<RequestIdMiddleware>();
// 2. Security Headers
app.UseMiddleware<SecurityHeadersMiddleware>();
// 3. HTTPS Redirection
app.UseHttpsRedirection();
// 4. IP Whitelist (for admin endpoints)
app.UseMiddleware<IpWhitelistMiddleware>();
// 5. Rate Limiting
app.UseMiddleware<RateLimitingMiddleware>();
// 6. Request Size Limiting
app.UseMiddleware<RequestSizeLimitMiddleware>();
// 7. CORS
app.UseCors("PlatformPolicy");
// 8. Request Logging (sanitized)
app.UseMiddleware<SanitizedRequestLoggingMiddleware>();
// 9. API Key Validation (service-to-service)
app.UseMiddleware<ApiKeyValidationMiddleware>();
// 10. Authentication
app.UseAuthentication();
// 11. JWT Custom Validation
app.UseMiddleware<JwtCustomValidationMiddleware>();
// 12. Authorization
app.UseAuthorization();
// 13. Session Validation
app.UseMiddleware<SessionValidationMiddleware>();
// 14. Anti-Replay
app.UseMiddleware<AntiReplayMiddleware>();
// 15. Input Sanitization
app.UseMiddleware<InputSanitizationMiddleware>();
// 16. Response Masking
app.UseMiddleware<ResponseMaskingMiddleware>();
app.MapControllers();
}
Let me walk through each one.
1. Request ID Middleware
Every request gets a unique correlation ID. This is first because every subsequent middleware uses it for logging.
csharppublic class RequestIdMiddleware
{
private readonly RequestDelegate _next;
public RequestIdMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var requestId = context.Request.Headers["X-Request-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString("N");
context.Items["RequestId"] = requestId;
context.Response.Headers["X-Request-Id"] = requestId;
using (LogContext.PushProperty("RequestId", requestId))
{
await _next(context);
}
}
}
2. Security Headers
We set security headers early so they're present even if a later middleware short-circuits:
csharppublic class SecurityHeadersMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["X-XSS-Protection"] = "0"; // Modern approach: rely on CSP
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; frame-ancestors 'none'";
context.Response.Headers["Permissions-Policy"] =
"camera=(), microphone=(), geolocation=()";
context.Response.Headers["Strict-Transport-Security"] =
"max-age=31536000; includeSubDomains; preload";
// Remove server identification
context.Response.Headers.Remove("Server");
context.Response.Headers.Remove("X-Powered-By");
await _next(context);
}
}
Note: We set X-XSS-Protection to 0 deliberately. The old 1; mode=block value can actually introduce XSS vulnerabilities in older browsers. Modern security relies on Content-Security-Policy instead.
3-4. HTTPS Redirection and IP Whitelisting
HTTPS redirection is built-in. IP whitelisting is conditional — it only applies to admin endpoints:
csharppublic class IpWhitelistMiddleware
{
private readonly HashSet<string> _allowedIps;
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.StartsWithSegments("/api/admin"))
{
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (remoteIp == null || !_allowedIps.Contains(remoteIp))
{
_logger.LogWarning("Blocked admin access from IP {Ip}", remoteIp);
context.Response.StatusCode = 403;
return;
}
}
await _next(context);
}
}
5. Rate Limiting
We use a sliding window algorithm with different limits per endpoint category:
csharppublic class RateLimitingMiddleware
{
private readonly IDistributedCache _cache;
public async Task InvokeAsync(HttpContext context)
{
var clientId = GetClientIdentifier(context);
var endpoint = GetEndpointCategory(context.Request.Path);
var limit = endpoint switch
{
"auth" => new RateLimit(5, TimeSpan.FromMinutes(1)),
"transactions" => new RateLimit(30, TimeSpan.FromMinutes(1)),
"queries" => new RateLimit(100, TimeSpan.FromMinutes(1)),
_ => new RateLimit(60, TimeSpan.FromMinutes(1)),
};
var key = $"rate:{clientId}:{endpoint}";
var current = await _cache.GetAsync<SlidingWindow>(key);
if (current != null && current.RequestCount >= limit.MaxRequests)
{
context.Response.StatusCode = 429;
context.Response.Headers["Retry-After"] =
current.WindowResetSeconds.ToString();
return;
}
await IncrementWindow(key, limit);
await _next(context);
}
}
Auth endpoints get a strict limit of 5 per minute to prevent brute force attacks. Query endpoints are more generous at 100 per minute.
6. Request Size Limiting
Financial APIs don't need to accept large payloads. We cap request bodies at 1MB:
csharppublic class RequestSizeLimitMiddleware
{
private const long MaxRequestSize = 1_048_576; // 1 MB
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.ContentLength > MaxRequestSize)
{
context.Response.StatusCode = 413;
await context.Response.WriteAsJsonAsync(new
{
error = "Request body too large"
});
return;
}
await _next(context);
}
}
8. Sanitized Request Logging
This middleware logs request details for audit purposes while stripping PII:
csharppublic class SanitizedRequestLoggingMiddleware
{
private static readonly HashSet<string> SensitiveFields = new()
{
"password", "token", "refreshToken", "ssn", "cardNumber",
"cvv", "pin", "accountNumber", "nationalId"
};
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
// Log sanitized request
if (context.Request.ContentType?.Contains("application/json") == true)
{
context.Request.EnableBuffering();
var body = await new StreamReader(context.Request.Body)
.ReadToEndAsync();
context.Request.Body.Position = 0;
var sanitized = SanitizeJson(body);
_logger.LogInformation(
"Request {Method} {Path} Body: {Body}",
context.Request.Method,
context.Request.Path,
sanitized);
}
await _next(context);
stopwatch.Stop();
_logger.LogInformation(
"Response {StatusCode} in {ElapsedMs}ms",
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
private string SanitizeJson(string json)
{
var doc = JsonDocument.Parse(json);
return SanitizeElement(doc.RootElement).ToString();
}
}
11. JWT Custom Validation
Beyond standard JWT validation, we check custom claims specific to our regulated application context:
csharppublic class JwtCustomValidationMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity?.IsAuthenticated != true)
{
await _next(context);
return;
}
var claims = context.User.Claims;
// Verify device fingerprint matches
var tokenDeviceId = claims.FirstOrDefault(
c => c.Type == "device_id")?.Value;
var requestDeviceId = context.Request.Headers["X-Device-Id"]
.FirstOrDefault();
if (tokenDeviceId != null && tokenDeviceId != requestDeviceId)
{
_logger.LogWarning(
"Device mismatch: token={TokenDevice} request={RequestDevice}",
tokenDeviceId, requestDeviceId);
context.Response.StatusCode = 401;
return;
}
// Verify token hasn't been revoked
var jti = claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti != null && await _tokenRevocationService.IsRevoked(jti))
{
context.Response.StatusCode = 401;
return;
}
await _next(context);
}
}
13. Session Validation
This enforces our 8-hour hard session timeout server-side:
csharppublic class SessionValidationMiddleware
{
private static readonly TimeSpan SessionTimeout = TimeSpan.FromHours(8);
public async Task InvokeAsync(HttpContext context)
{
var sessionStart = context.User.Claims
.FirstOrDefault(c => c.Type == "session_start")?.Value;
if (sessionStart != null)
{
var startTime = DateTimeOffset.FromUnixTimeSeconds(
long.Parse(sessionStart));
if (DateTimeOffset.UtcNow - startTime > SessionTimeout)
{
context.Response.StatusCode = 401;
context.Response.Headers["X-Session-Expired"] = "true";
return;
}
}
await _next(context);
}
}
14. Anti-Replay
Prevents replay attacks by ensuring each request nonce is used only once:
csharppublic class AntiReplayMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Method == "GET")
{
await _next(context);
return;
}
var nonce = context.Request.Headers["X-Request-Nonce"].FirstOrDefault();
var timestamp = context.Request.Headers["X-Request-Timestamp"]
.FirstOrDefault();
if (string.IsNullOrEmpty(nonce) || string.IsNullOrEmpty(timestamp))
{
context.Response.StatusCode = 400;
return;
}
// Reject requests older than 5 minutes
if (long.TryParse(timestamp, out var ts))
{
var requestTime = DateTimeOffset.FromUnixTimeSeconds(ts);
if (Math.Abs((DateTimeOffset.UtcNow - requestTime).TotalMinutes) > 5)
{
context.Response.StatusCode = 400;
return;
}
}
var key = $"nonce:{nonce}";
var exists = await _cache.GetAsync(key);
if (exists != null)
{
_logger.LogWarning("Replay attack detected. Nonce: {Nonce}", nonce);
context.Response.StatusCode = 409;
return;
}
await _cache.SetAsync(key, new byte[] { 1 },
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
await _next(context);
}
}
15-16. Input Sanitization and Response Masking
Input sanitization strips potential injection vectors. Response masking ensures sensitive data like full account numbers are partially redacted in API responses:
csharppublic class ResponseMaskingMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
var originalBody = context.Response.Body;
using var newBody = new MemoryStream();
context.Response.Body = newBody;
await _next(context);
newBody.Seek(0, SeekOrigin.Begin);
var responseText = await new StreamReader(newBody).ReadToEndAsync();
if (context.Response.ContentType?.Contains("application/json") == true)
{
responseText = MaskSensitiveFields(responseText);
}
context.Response.Body = originalBody;
await context.Response.WriteAsync(responseText);
}
private string MaskSensitiveFields(string json)
{
// accountNumber: "1234567890" -> "******7890"
// email: "[email protected]" -> "u***@example.com"
// phone: "+905551234567" -> "+90*****4567"
// Implementation uses regex patterns per field type
}
}
Performance Impact
The natural question: what's the performance cost of 16 middlewares? We measured each one:
| Middleware | Avg Latency Added | |-----------|------------------| | Request ID | <0.1ms | | Security Headers | <0.1ms | | IP Whitelist | <0.1ms | | Rate Limiting | ~1ms (Redis lookup) | | Request Size | <0.1ms | | Request Logging | ~2ms (with sanitization) | | JWT Custom Validation | ~1ms (revocation check) | | Session Validation | <0.1ms | | Anti-Replay | ~1ms (Redis lookup) | | Input Sanitization | ~1ms | | Response Masking | ~2ms | | Total overhead | ~8-10ms |
Ten milliseconds per request is a price I'll gladly pay for defense in depth. In a sensitive application, a single security breach costs orders of magnitude more than the cumulative latency overhead of the entire product lifecycle.
These 16 middlewares represent lessons learned from security audits, penetration tests, and real-world incidents. Each one exists because we identified a concrete attack vector it mitigates. Security isn't about checking boxes — it's about layered defense where no single point of failure can compromise the system.