Back to Blog
.NETSecurityMiddlewareOWASPEnterprise

16 Security Middlewares: Building a Hardened .NET API Pipeline

Umut Korkmaz2025-04-1514 min read

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.

csharp
public 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:

csharp
public 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:

csharp
public 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:

csharp
public 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:

csharp
public 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:

csharp
public 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:

csharp
public 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:

csharp
public 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:

csharp
public 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:

csharp
public 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.