On this page

Middleware and filters in ASP.NET Core

12 min read TextCh. 5 — Production

The middleware pipeline

In ASP.NET Core, every HTTP request passes through a chain of middleware. Each middleware decides whether to process the request and whether to pass it to the next:

Request ──→ Middleware 1 ──→ Middleware 2 ──→ ... ──→ Endpoint
              ↑                   ↑                       ↑
              └── next() ─────────┘── next() ─────────────┘
Response ←─── Middleware 1 ←─── Middleware 2 ←─── ... ←─── Endpoint

Recommended order in ASP.NET Core:

var app = builder.Build();

// 1. Exception handling — FIRST
app.UseExceptionHandler("/error");
app.UseHsts();

// 2. HTTPS redirect
app.UseHttpsRedirection();

// 3. Static files
app.UseStaticFiles();

// 4. Routing
app.UseRouting();

// 5. CORS
app.UseCors("PolicyName");

// 6. Authentication → Authorization
app.UseAuthentication();
app.UseAuthorization();

// 7. Endpoints — LAST
app.MapControllers();
app.Run();

Types of middleware

Use — chained (most common)

app.Use(async (context, next) =>
{
    // Before the next layer
    Console.WriteLine($"Before: {context.Request.Path}");

    await next.Invoke(context); // pass to next

    // After the next layer
    Console.WriteLine($"After: {context.Response.StatusCode}");
});

Run — terminal (does not chain)

// Intercepts ALL requests that reach this point
app.Run(async context =>
{
    context.Response.StatusCode = 404;
    await context.Response.WriteAsync("Route not found");
});

Map — branch by route

// Only processes requests to /health
app.Map("/health", branch =>
{
    branch.Run(async ctx =>
    {
        ctx.Response.ContentType = "application/json";
        await ctx.Response.WriteAsJsonAsync(new { status = "healthy" });
    });
});

Custom middleware with a class

The recommended approach for middleware that needs dependency injection:

// Option 1: IMiddleware (cleaner, constructor DI)
public class RateLimitMiddleware : IMiddleware
{
    private static readonly Dictionary<string, (int Count, DateTime Reset)> _hits = new();
    private readonly ILogger<RateLimitMiddleware> _logger;

    public RateLimitMiddleware(ILogger<RateLimitMiddleware> logger)
        => _logger = logger;

    public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
    {
        var ip      = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        var now     = DateTime.UtcNow;
        var window  = TimeSpan.FromMinutes(1);

        if (_hits.TryGetValue(ip, out var state))
        {
            if (now < state.Reset && state.Count >= 60) // 60 req/min
            {
                _logger.LogWarning("Rate limit exceeded for IP {Ip}", ip);
                ctx.Response.StatusCode = 429; // Too Many Requests
                ctx.Response.Headers["Retry-After"] = "60";
                await ctx.Response.WriteAsJsonAsync(new { error = "Too many requests" });
                return;
            }

            if (now >= state.Reset)
                _hits[ip] = (1, now.Add(window));
            else
                _hits[ip] = (state.Count + 1, state.Reset);
        }
        else
        {
            _hits[ip] = (1, now.Add(window));
        }

        await next(ctx);
    }
}

// Option 2: Convention-based (InvokeAsync with DI parameter)
public class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext ctx)
    {
        // DI via InvokeAsync parameter
        var correlationId = ctx.Request.Headers["X-Correlation-Id"]
            .FirstOrDefault() ?? Guid.NewGuid().ToString();

        ctx.Items["CorrelationId"] = correlationId;
        ctx.Response.Headers["X-Correlation-Id"] = correlationId;

        await _next(ctx);
    }
}

Registering and using middleware

// Register in the DI container
builder.Services.AddTransient<RateLimitMiddleware>();
builder.Services.AddTransient<LoggingMiddleware>();
builder.Services.AddTransient<ExceptionMiddleware>();

// Extension method for clean usage
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRateLimit(this IApplicationBuilder app)
        => app.UseMiddleware<RateLimitMiddleware>();

    public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
        => app.UseMiddleware<LoggingMiddleware>();
}

// In Program.cs
app.UseMiddleware<ExceptionMiddleware>(); // first
app.UseRequestLogging();
app.UseRateLimit();

Action filters

Filters are middleware specific to the MVC/controllers pipeline:

Type When it runs
IAuthorizationFilter Before the action — decides whether to proceed
IResourceFilter After routing, before model binding
IActionFilter Just before/after the action
IResultFilter Before/after the result is written
IExceptionFilter When there is an unhandled exception
// API Key validation filter as an attribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class ApiKeyAttribute : Attribute, IAuthorizationFilter
{
    public void OnAuthorization(AuthorizationFilterContext context)
    {
        if (!context.HttpContext.Request.Headers.TryGetValue("X-API-Key", out var key))
        {
            context.Result = new UnauthorizedObjectResult(
                new { error = "API Key required" });
            return;
        }

        var expectedKey = context.HttpContext.RequestServices
            .GetRequiredService<IConfiguration>()["ApiKey"];

        if (key != expectedKey)
            context.Result = new ForbidResult();
    }
}

// Usage
[ApiKey]
[HttpGet("private-data")]
public IActionResult GetPrivateData() => Ok("Only with API Key");

Global exception handling with ProblemDetails

// ASP.NET Core 7+ — UseExceptionHandler with ProblemDetails
builder.Services.AddProblemDetails();

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async ctx =>
    {
        var exceptionHandlerFeature = ctx.Features
            .Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();

        var exception = exceptionHandlerFeature?.Error;

        ctx.Response.StatusCode = exception switch
        {
            ArgumentException           => 400,
            UnauthorizedAccessException => 401,
            KeyNotFoundException         => 404,
            _                           => 500
        };

        await ctx.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Title    = "An error occurred",
            Detail   = exception?.Message,
            Status   = ctx.Response.StatusCode,
            Instance = ctx.Request.Path
        });
    });
});

Practice

  1. Logging middleware: Implement RequestLoggingMiddleware that logs method, path, response code, and execution time using ILogger.
  2. Correlation filter: Create an ActionFilter that adds an X-Request-Id header with a unique GUID to every response.
  3. Exception handling: Implement an ExceptionMiddleware that converts NotFoundException to 404, ValidationException to 400, and anything else to 500 with a consistent JSON body.

In the next lesson we will learn how to write tests in .NET with xUnit, Moq, and WebApplicationFactory for APIs.

Pipeline order matters
Middleware executes in the order it is registered with app.Use*. Exception handling MUST be first (to catch errors from any subsequent middleware). Authentication MUST come before Authorization. UseRouting MUST come before UseAuthorization and UseEndpoints.
IMiddleware vs inline middleware
IMiddleware allows constructor dependency injection and is easier to test. Inline middleware (app.Use((ctx, next) => ...)) is useful for simple one-or-two-line logic. For middleware that needs services (logging, DB), always use IMiddleware.
Do not forget to register IMiddleware in DI
Unlike inline middleware, IMiddleware must be registered in the DI container. Add builder.Services.AddTransient<LoggingMiddleware>() before builder.Build(). If you forget the registration you will get an InvalidOperationException at startup.
// ── Middleware with IMiddleware (DI-friendly) ─────────
public class LoggingMiddleware : IMiddleware
{
    private readonly ILogger<LoggingMiddleware> _logger;

    public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
        => _logger = logger;

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        var sw     = System.Diagnostics.Stopwatch.StartNew();
        var method = context.Request.Method;
        var path   = context.Request.Path;

        _logger.LogInformation("→ {Method} {Path}", method, path);

        try
        {
            await next(context);  // call the next middleware
        }
        finally
        {
            sw.Stop();
            _logger.LogInformation(
                "← {Method} {Path} {Status} ({Ms}ms)",
                method, path,
                context.Response.StatusCode,
                sw.ElapsedMilliseconds);
        }
    }
}

// ── Global exception handling middleware ──────────────
public class ExceptionMiddleware : IMiddleware
{
    private readonly ILogger<ExceptionMiddleware> _logger;

    public ExceptionMiddleware(ILogger<ExceptionMiddleware> logger)
        => _logger = logger;

    public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
    {
        try
        {
            await next(ctx);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled error");
            ctx.Response.StatusCode  = 500;
            ctx.Response.ContentType = "application/json";
            await ctx.Response.WriteAsJsonAsync(new
            {
                error   = "Internal server error",
                traceId = ctx.TraceIdentifier
            });
        }
    }
}