Custom middleware lets you add reusable request/response processing to the entire pipeline. The canonical use cases are: adding security headers to every response, correlating requests with IDs, measuring and logging request duration, enforcing rate limits, and transforming request or response content. Middleware runs for every request to the application (unlike action filters which run only for controller actions), making it the right place for cross-cutting concerns that apply globally.
Convention-Based Middleware
// ── Convention-based middleware — InvokeAsync method, DI via constructor ──
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
// Singleton-lifetime constructor (called once)
public SecurityHeadersMiddleware(RequestDelegate next)
=> _next = next;
// InvokeAsync — called per request; use DI parameters for scoped services
public async Task InvokeAsync(HttpContext context)
{
// Add security headers to every response
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["X-XSS-Protection"] = "1; mode=block";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
if (!context.Request.IsHttps)
{
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; upgrade-insecure-requests";
}
await _next(context);
}
}
// ── Register in Program.cs ────────────────────────────────────────────────
app.UseMiddleware<SecurityHeadersMiddleware>();
ILogger<T> which is actually singleton-safe, or IPostRepository which is scoped) should be injected as InvokeAsync parameters, not constructor parameters. ASP.NET Core resolves InvokeAsync parameters from the request’s DI scope on each call. Injecting a Scoped service in the constructor would capture it at startup (singleton lifetime), creating the captive dependency bug.IMiddleware interface pattern instead of the convention-based pattern. IMiddleware is registered as a transient or scoped service in DI, so it is created fresh per request: public class MyMiddleware : IMiddleware { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { ... } } then builder.Services.AddScoped<MyMiddleware>() and app.UseMiddleware<MyMiddleware>(). This eliminates the captive dependency concern entirely.if (context.Request.Path.StartsWithSegments("/api"))) to skip middleware for paths that do not need it.IMiddleware Interface Pattern
// ── IMiddleware — scoped/transient, can inject scoped services ─────────────
public class RequestTimingMiddleware : IMiddleware
{
private readonly ILogger<RequestTimingMiddleware> _logger;
// Constructor injection works normally — IMiddleware is created per request
public RequestTimingMiddleware(ILogger<RequestTimingMiddleware> logger)
=> _logger = logger;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var sw = Stopwatch.StartNew();
try
{
await next(context);
}
finally
{
sw.Stop();
_logger.LogInformation(
"{Method} {Path} completed in {ElapsedMs}ms → {StatusCode}",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
// ── Register as scoped in DI, then add to pipeline ────────────────────────
builder.Services.AddScoped<RequestTimingMiddleware>();
// ...
app.UseMiddleware<RequestTimingMiddleware>();
Common Mistakes
Mistake 1 — Injecting scoped services in convention-based middleware constructor
❌ Wrong — scoped service captured as singleton; state leaks between requests:
public class MyMiddleware(RequestDelegate next, IPostRepository repo) { }
// repo is Scoped — captured in singleton middleware constructor!
✅ Correct — inject scoped services as InvokeAsync parameters or use IMiddleware.
Mistake 2 — Running expensive operations in middleware for all requests
❌ Wrong — database lookup for every request including health checks and static files.
✅ Correct — check the request path first and skip middleware for irrelevant routes.