Skip to main content

Finished reading? Get articles like this every Tuesday

Middlewares in ASP.NET Core .NET 10 - The Complete Guide

Master ASP.NET Core middleware in .NET 10 - execution order, custom middleware, IMiddleware, short-circuiting, and the middleware vs filters decision matrix with examples.

dotnet webapi-course

middlewares middleware asp.net-core dotnet-10 request-pipeline http webapi custom-middleware imiddleware middleware-vs-filters endpoint-filters short-circuiting middleware-order middleware-best-practices cross-cutting-concerns dotnet-webapi-zero-to-hero-course request-delegate httpcontext

15 min read
75.7K views

I once spent 4 hours debugging a production API where authenticated users were randomly getting 401 responses. The code looked fine. The JWT validation was correct. The claims were present. Turns out, someone had registered UseAuthorization() before UseAuthentication() - two lines in the wrong order, and the entire auth pipeline silently failed. That is the power (and danger) of middleware ordering in ASP.NET Core.

Middleware is the backbone of how ASP.NET Core processes every single HTTP request. If you do not understand it deeply, you will hit bugs that are nearly impossible to trace. In this guide, I will break down exactly how middleware works in .NET 10 - from the request pipeline fundamentals to building custom middleware, the IMiddleware interface, and a decision matrix for when to use middleware vs filters. Let’s get into it.

What are Middlewares in ASP.NET Core?

Middleware in ASP.NET Core is a component that sits in the HTTP request pipeline and can inspect, modify, or short-circuit both requests and responses. Every HTTP request that hits your .NET 10 application passes through a chain of middleware components before reaching your endpoint logic, and the response passes back through the same chain in reverse order.

Think of it as an assembly line - each station (middleware) does one specific job: logging, authentication, CORS headers, compression. The request moves from station to station, and the response travels back the same path.

According to Microsoft’s official documentation, middleware components are assembled into the application pipeline to handle requests and responses. Each component chooses whether to pass the request to the next component and can perform work before and after the next component in the pipeline.

Here is what makes middleware powerful in practice:

  • Pipeline control - each middleware decides whether to pass the request forward or stop it
  • Bidirectional processing - middleware can run logic on both the request (incoming) and response (outgoing) paths
  • Composable - you register middleware in Program.cs using app.Use, app.Run, and app.Map, building the pipeline like LEGO blocks
  • Order-dependent - the sequence you register middleware determines the execution order, and getting it wrong leads to subtle bugs (like my auth story above)

How Middlewares Work

Each middleware component in the pipeline follows a simple pattern: receive the HttpContext, do some processing, then either call the next middleware or short-circuit by generating a response directly. If a middleware short-circuits, all downstream middleware components are skipped - the response flows back through only the upstream middleware components that already executed.

ASP.NET Core middleware pipeline showing request flowing through Middleware 1, 2, and 3 then response returning in reverse order

The key insight is that middleware is bidirectional. Code before await next() runs on the request path (incoming). Code after await next() runs on the response path (outgoing). This is how logging middleware can capture both the incoming request details and the outgoing response status code in a single component.

Middleware Execution Order

The order in which you register middleware in Program.cs is the order they execute. This is not a suggestion - it is a hard rule that ASP.NET Core enforces. Getting it wrong does not throw an exception; it just silently misbehaves.

When a request enters the pipeline, it flows through each middleware in registration order. If a middleware calls await next(), the request continues to the next component. The response then passes back through the middleware in reverse order.

app.Use(async (context, next) =>
{
Console.WriteLine("Middleware 1: Incoming request");
await next();
Console.WriteLine("Middleware 1: Outgoing response");
});
app.Use(async (context, next) =>
{
Console.WriteLine("Middleware 2: Incoming request");
await next();
Console.WriteLine("Middleware 2: Outgoing response");
});
app.Run(async context =>
{
Console.WriteLine("Middleware 3: Terminal - handling request");
await context.Response.WriteAsync("Hello, world!");
});

The console output:

Middleware 1: Incoming request
Middleware 2: Incoming request
Middleware 3: Terminal - handling request
Middleware 2: Outgoing response
Middleware 1: Outgoing response

Notice the nesting pattern - it is like Russian dolls. Middleware 1 wraps Middleware 2, which wraps Middleware 3. This is why exception handling middleware must be registered first: it needs to wrap everything else to catch exceptions from any downstream component.

The Ordering Bug That Cost Me 4 Hours

Let me expand on the story from the intro because it illustrates exactly why ordering matters. The production Program.cs looked like this:

app.UseAuthorization(); // Checking permissions...
app.UseAuthentication(); // ...before knowing who the user is!

Authorization was running before authentication had established the user’s identity. The result? context.User was an anonymous ClaimsPrincipal when the authorization middleware checked it, so [Authorize] attributes randomly failed depending on whether the auth cookie or JWT had been processed by something else first.

The fix was a single line swap. But finding it took 4 hours because middleware ordering bugs produce no errors - they just produce wrong behavior. My take: always follow the recommended order (which I cover below), and if auth-related behavior feels random, check your middleware order first.

Request Delegates and HttpContext

At the core of every middleware component are two concepts: request delegates and HttpContext. Understanding these is essential for building custom middleware.

Request Delegates

A request delegate is a function that processes an HTTP request. It is the building block of the middleware pipeline. There are three ways to define them:

1. Inline middleware with app.Use - passes to next

app.Use(async (context, next) =>
{
Console.WriteLine($"Request: {context.Request.Method} {context.Request.Path}");
await next();
Console.WriteLine($"Response: {context.Response.StatusCode}");
});

2. Terminal middleware with app.Run - short-circuits

app.Run(async context =>
{
await context.Response.WriteAsync("Terminal middleware - pipeline stops here.");
});

3. Convention-based middleware class

public class RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
logger.LogInformation("Incoming {Method} {Path}", context.Request.Method, context.Request.Path);
await next(context);
logger.LogInformation("Completed with {StatusCode}", context.Response.StatusCode);
}
}

Register it in Program.cs:

app.UseMiddleware<RequestLoggingMiddleware>();

HttpContext

HttpContext is the object that carries everything about the current HTTP request and response. Every middleware receives it, and it is how middleware components communicate with each other and with your endpoint logic.

Key properties you will use constantly:

// Request information
string method = context.Request.Method; // GET, POST, PUT, DELETE
string path = context.Request.Path; // /api/products
string query = context.Request.QueryString.ToString();
string userAgent = context.Request.Headers["User-Agent"]!;
// Response control
context.Response.StatusCode = 200;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { message = "OK" });
// User identity (after authentication middleware)
bool isAuthenticated = context.User.Identity?.IsAuthenticated ?? false;
// Shared state between middleware components
context.Items["CorrelationId"] = Guid.NewGuid().ToString();

Each request delegate receives the HttpContext, processes it, and either calls the next middleware with await next(context) or returns a response directly to short-circuit.

Built-In Middlewares in .NET 10

ASP.NET Core ships with middleware for the most common cross-cutting concerns. In .NET 10, these are the ones you will use in nearly every Web API:

Exception Handling Middleware

Catches unhandled exceptions and returns structured error responses. In .NET 10, use the IExceptionHandler pattern for custom handling.

app.UseExceptionHandler();

Routing Middleware

Maps incoming requests to endpoints. In .NET 10 with minimal APIs, routing is implicit - you rarely need to call UseRouting() explicitly unless you have middleware that must run between routing and endpoint execution.

app.UseRouting();

Authentication and Authorization Middleware

Establishes user identity and enforces access policies. Always register authentication before authorization.

app.UseAuthentication();
app.UseAuthorization();

HTTPS Redirection Middleware

Redirects HTTP requests to HTTPS.

app.UseHttpsRedirection();

CORS Middleware

Controls cross-origin request access for browser-based clients.

app.UseCors("AllowFrontend");

Response Compression Middleware

Compresses responses using gzip or Brotli to reduce bandwidth.

app.UseResponseCompression();

Output Caching Middleware (.NET 10)

Server-side response caching with tag-based invalidation - added in .NET 7 and significantly improved in .NET 10.

app.UseOutputCache();

Rate Limiting Middleware (.NET 10)

Built-in rate limiting with fixed window, sliding window, token bucket, and concurrency algorithms.

app.UseRateLimiter();

Static Files Middleware

Serves static content from wwwroot. Useful for SPAs or hybrid apps.

app.UseStaticFiles();

Request Logging Middleware

If you use Serilog, UseSerilogRequestLogging() provides structured HTTP request logs with timing, status codes, and path information.

app.UseSerilogRequestLogging();

Custom Middleware - Convention-Based

While built-in middleware covers common scenarios, you will need custom middleware for application-specific concerns like correlation IDs, maintenance mode, or request timing. The convention-based approach is the most common way to create custom middleware in ASP.NET Core.

A convention-based middleware class must:

  1. Accept a RequestDelegate in its constructor (represents the next middleware)
  2. Implement an InvokeAsync method that takes HttpContext and returns Task
  3. Call await next(context) to pass control to the next middleware (or skip it to short-circuit)

Request Logging Middleware

Here is a production-ready logging middleware from the companion code repo:

public class RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var startTime = DateTime.UtcNow;
logger.LogInformation("Incoming {Method} {Path}", context.Request.Method, context.Request.Path);
await next(context);
var elapsed = DateTime.UtcNow - startTime;
logger.LogInformation("Completed {Method} {Path} with {StatusCode} in {Elapsed}ms",
context.Request.Method, context.Request.Path, context.Response.StatusCode, elapsed.TotalMilliseconds);
}
}

Notice the use of primary constructors (C# 12+) - cleaner than the traditional constructor pattern. Also, I am using structured log message templates ({Method}, {Path}) instead of string interpolation. Trust me, this matters when you query logs in Seq or Application Insights.

Registering Custom Middleware

You can register it directly:

app.UseMiddleware<RequestLoggingMiddleware>();

Or create a clean extension method (my preferred approach):

public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
{
return app.UseMiddleware<RequestLoggingMiddleware>();
}
}

Then in Program.cs:

app.UseRequestLogging();

This keeps Program.cs readable, especially when you have 5+ custom middleware components. Quite handy, yeah?

IMiddleware Interface - The DI-Friendly Alternative

Convention-based middleware has a limitation: it is registered as a singleton by default. The constructor runs once when the application starts, and the same instance handles all requests. This means you cannot inject scoped services (like DbContext) directly into the constructor.

The IMiddleware interface solves this. When you implement IMiddleware, ASP.NET Core uses the middleware factory pattern - it resolves the middleware from the DI container per request, respecting service lifetimes.

public class CorrelationIdMiddleware(ILogger<CorrelationIdMiddleware> logger) : IMiddleware
{
private const string CorrelationIdHeader = "X-Correlation-Id";
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Items["CorrelationId"] = correlationId;
context.Response.Headers[CorrelationIdHeader] = correlationId;
using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
{
logger.LogInformation("Request {Path} assigned CorrelationId {CorrelationId}",
context.Request.Path, correlationId);
await next(context);
}
}
}

Critical difference: With IMiddleware, you must register the middleware class in the DI container and add it to the pipeline:

// Step 1: Register in DI container
builder.Services.AddTransient<CorrelationIdMiddleware>();
// Step 2: Add to pipeline
app.UseMiddleware<CorrelationIdMiddleware>();

Convention-Based vs IMiddleware - When to Use Which

Use convention-based middleware (the default) when:

  • Your middleware only needs singleton services (like ILogger, IConfiguration)
  • You do not need scoped services from the DI container
  • You want simpler registration (no DI registration needed)

Use IMiddleware when:

  • You need scoped services like DbContext or IHttpClientFactory
  • You want the middleware resolved per-request from the DI container
  • You need fine-grained control over service lifetimes

In my experience, about 80% of custom middleware works fine with the convention-based approach. I reach for IMiddleware when I need database access or when the middleware manages request-scoped state.

Short-Circuiting the Pipeline

Short-circuiting means a middleware returns a response without calling next(), preventing all downstream middleware from executing. This is useful for maintenance mode, authentication failures, rate limiting, or returning cached responses.

Basic Short-Circuit - Maintenance Mode

public class MaintenanceMiddleware(RequestDelegate next, IConfiguration configuration)
{
public async Task InvokeAsync(HttpContext context)
{
var isMaintenanceMode = configuration.GetValue<bool>("MaintenanceMode");
if (isMaintenanceMode)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
Status = 503,
Message = "Service is under maintenance. Please try again later."
});
return; // Short-circuit - do not call next
}
await next(context);
}
}

Register this before other middleware so it can block all requests during maintenance:

app.UseMaintenance();
app.UseAuthentication();
app.UseAuthorization();
// ...

Conditional Middleware with UseWhen and MapWhen

ASP.NET Core provides UseWhen and MapWhen for conditionally branching the middleware pipeline.

UseWhen - runs middleware for matching requests, then rejoins the main pipeline:

app.UseWhen(
context => context.Request.Path.StartsWithSegments("/api"),
appBuilder => appBuilder.UseMiddleware<RequestLoggingMiddleware>()
);

This runs RequestLoggingMiddleware only for requests to /api/*, but those requests still continue through the rest of the pipeline.

MapWhen - runs middleware for matching requests and forks into a separate pipeline:

app.MapWhen(
context => context.Request.Path.StartsWithSegments("/health"),
appBuilder => appBuilder.Run(async context =>
{
await context.Response.WriteAsJsonAsync(new { Status = "Healthy" });
})
);

Health check requests are handled entirely by this branch - they never reach your main pipeline. This is useful for endpoints that should bypass authentication, logging, and other middleware entirely.

Middleware vs Filters vs Endpoint Filters

This is the question I get asked most often: “Should I use middleware, an action filter, or an endpoint filter?” Here is the decision matrix I use:

ConcernMiddlewareAction FiltersEndpoint Filters
ScopeEntire HTTP pipelineMVC controllers onlyPer minimal API endpoint
DI accessManual or via IMiddlewareFull (constructor injection)Full (constructor injection)
ModelState accessNoYesNo
Runs for static filesYesNoNo
Runs for health checksYesNoDepends on registration
Can short-circuitYes (skip next())Yes (set Result)Yes (return early)
Access to endpoint metadataNo (runs before routing)YesYes
Best forLogging, CORS, compression, authValidation, caching, model transformsMinimal API validation, per-route auth

My Decision Rules

Use middleware when the concern applies to every request regardless of endpoint - logging, CORS, compression, exception handling, authentication. If you find yourself adding the same filter to every controller, that is a sign it should be middleware instead.

Use action filters when you need access to MVC-specific context like ModelState, action arguments, or action results. Validation filters, response caching attributes, and audit logging per action are good examples.

Use endpoint filters when you are building minimal APIs and need per-endpoint validation or transformation. Endpoint filters are the minimal API equivalent of action filters.

My take: Start with middleware for cross-cutting concerns. Reach for filters only when you need endpoint-specific behavior or MVC context. If you are mixing minimal APIs and controllers in the same project, endpoint filters and action filters handle their respective worlds - middleware handles everything.

Best Practices for Middleware in .NET 10

These are the practices I follow after years of building production ASP.NET Core APIs.

1. Keep Middleware Focused on One Responsibility

Each middleware should do one thing. If your middleware is logging requests and validating tokens and adding CORS headers, split it into three. This makes each component testable and replaceable.

Here is the order I use in every .NET 10 Web API project:

var app = builder.Build();
app.UseExceptionHandler(); // 1. Catch all unhandled exceptions
app.UseHttpsRedirection(); // 2. Redirect HTTP → HTTPS
app.UseRouting(); // 3. Match routes
app.UseCors(); // 4. CORS headers
app.UseAuthentication(); // 5. Establish identity
app.UseAuthorization(); // 6. Check permissions
app.UseOutputCache(); // 7. Serve cached responses
app.UseRateLimiter(); // 8. Enforce rate limits
app.UseResponseCompression(); // 9. Compress responses
app.UseMiddleware<RequestLoggingMiddleware>();// 10. Custom middleware
app.MapControllers(); // 11. Execute endpoints
app.Run();

Notice there is no UseEndpoints() - in .NET 10, MapControllers() and MapGet/Post/Put/Delete() handle routing implicitly. Also, UseOutputCache() goes after authorization so cached responses still respect auth policies, and UseRateLimiter() goes after authentication so you can apply per-user rate limits.

3. Never Block the Pipeline with Synchronous Calls

Always use async/await. A synchronous .Result or .Wait() call in middleware blocks the thread pool and can deadlock under load.

// Never do this
public void Invoke(HttpContext context)
{
var result = SomeLongRunningOperation().Result; // Deadlock risk!
}
// Always do this
public async Task InvokeAsync(HttpContext context)
{
var result = await SomeLongRunningOperation();
}

4. Use Extension Methods for Clean Registration

Keep Program.cs readable by wrapping middleware registration in extension methods:

public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRequestLogging(this IApplicationBuilder app)
=> app.UseMiddleware<RequestLoggingMiddleware>();
public static IApplicationBuilder UseMaintenance(this IApplicationBuilder app)
=> app.UseMiddleware<MaintenanceMiddleware>();
public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder app)
=> app.UseMiddleware<CorrelationIdMiddleware>();
}

5. Prefer Built-In Middleware Over Custom

ASP.NET Core’s built-in middleware is battle-tested and optimized. Before writing custom middleware for CORS, compression, rate limiting, or caching - check if there is a built-in option first. Custom middleware is for application-specific logic that built-in components do not cover.

6. Use IMiddleware When You Need Scoped Services

If your middleware needs DbContext, HttpClient, or any scoped service, implement IMiddleware instead of the convention-based approach. Convention-based middleware is singleton by default, and injecting scoped services into a singleton causes runtime exceptions.

7. Test Middleware in Isolation

Middleware is a function - it takes HttpContext and RequestDelegate and does something. You can test it by creating a DefaultHttpContext and a mock next delegate:

var context = new DefaultHttpContext();
var nextCalled = false;
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
var middleware = new RequestLoggingMiddleware(next, NullLogger<RequestLoggingMiddleware>.Instance);
await middleware.InvokeAsync(context);
Assert.True(nextCalled);

Troubleshooting Middleware Issues

Here are the issues I see most frequently when debugging middleware problems:

Middleware running in the wrong order? ASP.NET Core executes middleware in the exact order you register them in Program.cs. If authentication is not working, check that UseAuthentication() comes before UseAuthorization(). If exceptions are not being caught, make sure UseExceptionHandler() is the very first middleware.

“Response has already started” exception? This happens when middleware tries to modify the response (headers, status code) after the response body has begun writing. The fix: do all response modifications before calling await next(), or check context.Response.HasStarted before writing.

public async Task InvokeAsync(HttpContext context)
{
await _next(context);
if (!context.Response.HasStarted)
{
context.Response.Headers["X-Custom-Header"] = "value";
}
}

Scoped service disposed in middleware? Convention-based middleware is singleton - it outlives the request scope. If you inject a scoped service like DbContext into the constructor, it will be disposed after the first request. Use IMiddleware instead, or resolve the service from context.RequestServices:

public async Task InvokeAsync(HttpContext context)
{
var dbContext = context.RequestServices.GetRequiredService<AppDbContext>();
// dbContext is properly scoped to this request
}

Custom middleware not being hit? Verify it is registered in Program.cs. If using IMiddleware, confirm the class is also registered in the DI container with builder.Services.AddTransient<YourMiddleware>(). Missing DI registration causes a silent failure.

Middleware logging shows duplicate entries? You likely have both UseSerilogRequestLogging() and a custom logging middleware. Pick one. If you need custom logic beyond what Serilog provides, remove UseSerilogRequestLogging() and keep your custom middleware.

next() called but downstream middleware is skipped? Check for a app.Run() registered before your middleware. app.Run() is terminal - it never calls next(), so anything registered after it in the pipeline is unreachable.

Key Takeaways

  • Middleware is the foundation of ASP.NET Core request processing - every HTTP request passes through the middleware pipeline before reaching your endpoints.
  • Order is everything. Register exception handling first, authentication before authorization, and custom middleware before endpoint execution.
  • Use convention-based middleware for most scenarios and IMiddleware when you need scoped DI services.
  • Short-circuit when appropriate - maintenance mode, rate limiting, and cached responses should return early without hitting downstream middleware.
  • Use the decision matrix - middleware for cross-cutting concerns, action filters for MVC-specific logic, endpoint filters for minimal API per-route behavior.

For more on building robust ASP.NET Core APIs, check out my guides on global exception handling, structured logging with Serilog, and Problem Details. If you are building a full API from scratch, my .NET Web API Zero to Hero course covers middleware as part of a complete learning path from your first endpoint to Docker deployment.

If you found this helpful, share it with your colleagues - and if there is a middleware pattern you would like me to cover, drop a comment and let me know.

Happy Coding :)

What is middleware in ASP.NET Core?

Middleware is a component in the ASP.NET Core request pipeline that processes HTTP requests and responses. Each middleware can inspect, modify, or short-circuit requests before they reach your endpoint logic, and modify responses on the way back. Middleware handles cross-cutting concerns like authentication, logging, CORS, and exception handling.

What is the correct middleware order in .NET 10?

The recommended order is: ExceptionHandler, HttpsRedirection, Routing, CORS, Authentication, Authorization, OutputCache, RateLimiter, ResponseCompression, custom middleware, then endpoint mapping (MapControllers/MapGet). Exception handling must be first to catch all errors, and authentication must come before authorization.

What is the difference between app.Use and app.Run?

app.Use registers middleware that calls next() to pass control to the next middleware - it is bidirectional and runs code on both request and response paths. app.Run registers terminal middleware that does not call next(), short-circuiting the pipeline. Use app.Use for pass-through middleware and app.Run for the final handler.

When should I use IMiddleware instead of convention-based middleware?

Use IMiddleware when your middleware needs scoped services like DbContext or IHttpClientFactory. Convention-based middleware is singleton by default, so injecting scoped services causes runtime errors. IMiddleware is resolved from the DI container per request, respecting service lifetimes. For middleware that only needs ILogger or IConfiguration, convention-based is simpler.

Should I use middleware or action filters in ASP.NET Core?

Use middleware for concerns that apply to every request regardless of endpoint - logging, CORS, compression, authentication. Use action filters when you need MVC-specific context like ModelState, action arguments, or action results. Use endpoint filters for per-route validation in minimal APIs. If you find yourself adding the same filter to every controller, it should probably be middleware.

How do I short-circuit the middleware pipeline?

Do not call next() in your middleware - instead, write a response directly to context.Response. For example, set context.Response.StatusCode = 503 and call WriteAsJsonAsync() to return a maintenance response. All downstream middleware is skipped, and the response flows back through upstream middleware only.

Why is my middleware not executing in ASP.NET Core?

Check three things: (1) the middleware is registered in Program.cs with app.UseMiddleware<T>(), (2) if using IMiddleware, the class is also registered in DI with builder.Services.AddTransient<T>(), and (3) no terminal middleware (app.Run) is registered before your middleware in the pipeline. Missing DI registration for IMiddleware causes silent failures.

Can middleware access dependency injection services in .NET 10?

Yes, in two ways. Convention-based middleware accepts services via constructor injection (singleton lifetime only) and method injection in InvokeAsync. IMiddleware is resolved from the DI container per request, supporting transient and scoped lifetimes. You can also resolve services manually via context.RequestServices.GetRequiredService<T>().

What's your Feedback?
Do let me know your thoughts around this article.

8,200+ .NET devs get this every Tuesday

Free weekly newsletter

Stay ahead in .NET

Tutorials Architecture DevOps AI

Once-weekly email. Best insights. No fluff.

Join 8,200+ developers · Delivered every Tuesday