Sitemap

ASP.NET Core Middleware Explained

7 min readJun 28, 2026

--

Middleware sits between the HTTP request and your controller. Every request in an ASP.NET Core app passes through a chain of middleware before reaching the endpoint, and the response passes back through the same chain in reverse.

Knowing how this pipeline works — and how the framework treats different kinds of middleware — saves you from a class of silent bugs that are hard to spot once they’re shipped. This is a reference for what middleware actually is, how to write it, and a few things about it that bite people in production.

The pipeline

When an HTTP request hits your ASP.NET Core app, it doesn’t go straight to your controller. It passes through a chain of middleware components first. This chain is called the pipeline.

  • Each middleware can inspect the request, modify it, pass it along, or stop it
  • The response travels back through the same chain in reverse
  • You build the pipeline in Program.cs by registering middleware in order
  • The order you register them is the order they run
  • Each component runs twice per request — once on the way in, once on the way out
var app = builder.Build();

app.UseExceptionHandler();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

Basic syntax

Three building blocks for adding middleware to the pipeline:

app.Use(...) — runs middleware and passes control to the next one.

app.Use(async (context, next) =>
{
// runs on the way in
await next();
// runs on the way out
});

app.Run(...) — terminal middleware. Doesn't call next. Anything registered after it never runs.

app.Run(async context =>
{
await context.Response.WriteAsync("Hello");
});

app.Map("/path", ...) — branches the pipeline for requests matching a path prefix.

app.Map("/admin", admin =>
{
admin.Run(async context =>
{
await context.Response.WriteAsync("Admin area");
});
});

The two parameters in app.Use are:

  • context — the HttpContext for the current request
  • next — a delegate that runs the rest of the pipeline

Calling await next() hands control to the next middleware. Skip it, and the pipeline stops there.

Order matters

The order you register middleware in Program.cs is the exact order they run. ASP.NET Core doesn't reorder, validate, or warn — wrong order doesn't throw, it silently misbehaves.

Standard pipeline for most ASP.NET Core APIs:

app.UseExceptionHandler();    // catches unhandled exceptions
app.UseHttpsRedirection(); // redirects HTTP to HTTPS
app.UseRouting(); // matches URL to endpoint
app.UseCors(); // applies CORS policy, handles preflight
app.UseAuthentication(); // reads token, populates HttpContext.User
app.UseAuthorization(); // checks user has permission
app.MapControllers(); // terminal — executes the endpoint

Why the order:

  • ExceptionHandler first — so it catches exceptions thrown by anything downstream. If it’s last, it can’t wrap what ran before it.
  • CORS before Authentication — browser preflight OPTIONS requests don't carry auth headers. If Authentication runs first, preflight gets 401, browser blocks the real request. Classic "works in Postman, breaks in browser" bug.
  • Authentication before Authorization — you can’t decide what a user is allowed to do until you know who they are. Authentication populates HttpContext.User; Authorization reads it.
  • Routing before Authorization — endpoint-specific policies like [Authorize(Roles="Admin")] need the matched endpoint to apply.

Wrong order produces no error. Auth-related bugs that feel random are almost always middleware order issues.

Writing your own middleware

ASP.NET Core ships with most of what you’ll need — CORS, auth, exception handling, compression. When the built-ins don’t cover something, you write your own. Common reasons: request logging, correlation IDs, API key checks, maintenance mode.

There are two ways to write custom middleware. They look similar but behave differently under the hood, and picking the wrong one is the source of a fairly common production bug.

Convention-based

The simpler of the two. Write a class with:

  • A constructor that takes RequestDelegate next as the first parameter
  • An InvokeAsync method that takes HttpContext

No interface. The framework finds the right methods by name.

public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;

public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
_logger.LogInformation("→ {Method} {Path}", context.Request.Method, context.Request.Path);
await _next(context);
_logger.LogInformation("← {StatusCode}", context.Response.StatusCode);
}
}

Register it:

app.UseMiddleware<RequestLoggingMiddleware>();

That’s it. No DI registration needed for the middleware class itself.

The catch is in the lifetime. The framework creates one instance at app startup and reuses it for every request for the rest of the app’s life. Effectively singleton, whether you wanted that or not.

This is fine as long as the services you inject in the constructor are also designed to be shared — ILogger, IConfiguration, IMemoryCache. It breaks the moment you try to inject something scoped, like DbContext. That's the next section.

IMiddleware

The other way to write custom middleware. Use it when your middleware needs a scoped service like DbContext.

Implement the IMiddleware interface. Two things change compared to convention-based:

  • RequestDelegate next moves from the constructor to the InvokeAsync parameter
  • You have to register the class in DI as well as the pipeline
public class AuditMiddleware : IMiddleware
{
private readonly AppDbContext _db;
public AuditMiddleware(AppDbContext db)
{
_db = db;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
_db.AuditLogs.Add(new AuditLog { Path = context.Request.Path });
await _db.SaveChangesAsync();
await next(context);
}
}

Register both in DI and in the pipeline:

builder.Services.AddScoped<AuditMiddleware>();   // required
app.UseMiddleware<AuditMiddleware>();

When the framework sees a middleware implementing IMiddleware, it switches its behavior. Instead of creating one instance at startup, it asks the DI container for a fresh instance on every request. That means scoped dependencies in the constructor are resolved fresh per request, exactly like they are in controllers — no captured references, no stale state.

The trade-off is a small DI resolution cost per request. Worth it when you need scoped services. Not worth it if you don’t.

The captive dependency problem

The reason IMiddleware exists.

Get Anchalmehandiratta’s stories in your inbox

Join Medium for free to get updates from this writer.

Convention-based middleware is created once at app startup and reused for every request. If you inject a scoped service like DbContext into its constructor, that DbContext gets stuck inside the middleware forever.

What happens:

  • App starts. Framework creates the middleware, asks DI for a DbContext, hands it over. The middleware stores it.
  • First request — works fine.
  • Request ends. Framework disposes the DbContext because it's scoped.
  • Second request — middleware still holds the disposed reference, tries to use it.
  • ObjectDisposedException.

This is a captive dependency — a short-lived service captured by a long-lived consumer, outliving its scope.

In Development, .NET catches this at startup and throws a clear error before the app runs. In Production, scope validation is off by default, so you get the runtime crash on request two with no startup warning.

IMiddleware solves it by resolving a fresh middleware instance per request — the constructor runs each time, gets a fresh DbContext, both get disposed together at the end.

Rule: if your middleware needs the database or any scoped service, use IMiddleware. Otherwise, convention-based is fine.

Short-circuiting

A middleware can stop the pipeline by not calling next(). Anything downstream — other middleware, the endpoint — never runs.

public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.ContainsKey("X-API-Key"))
{
context.Response.StatusCode = 401;
return; // pipeline stops here
}
await _next(context);
}

Common uses:

  • Missing or invalid auth → return 401
  • Rate limit exceeded → return 429
  • Maintenance mode → return 503
  • Cached response available → write it directly, skip the rest

The middleware is responsible for writing the response itself when it short-circuits — the endpoint never gets a chance.

Middleware vs Filter

Both wrap behavior around requests, but they run at different layers.

Middleware runs at the HTTP pipeline level. It sees the raw HttpContext but doesn't know about MVC concepts — controllers, actions, model state. It runs for every request, including static files and health checks. Use it for cross-cutting HTTP concerns:

  • CORS
  • Compression
  • Exception handling
  • Authentication
  • Request logging

Filters run inside the MVC pipeline, after routing has matched a controller action. They have access to the action method, its arguments, the model state, and the result. They only run for requests routed to controllers. Use them for MVC-specific concerns:

  • [Authorize]
  • Model validation
  • Response wrapping
  • Action-level caching

Rule of thumb: if the concern applies to every HTTP request, use middleware. If it needs MVC context — the action, model, result — use a filter. When in doubt, middleware is the safer default.

HttpContext

The object representing the current HTTP request and response. Every middleware receives it. One per request — created when the request arrives, disposed when the response completes.

It does two things:

Carries the request and response. Holds everything about the incoming request and everything you’re building up for the outgoing response.

  • Request — method, path, headers, query, body
  • Response — status code, headers, body

Carries shared state between middleware. As the pipeline runs, each middleware can read and write data on the context that downstream middleware can pick up.

  • User — populated by UseAuthentication, read by UseAuthorization and anything else that needs to know who the user is
  • Items — a per-request dictionary for passing custom data between your own middleware components
// early middleware sets it
context.Items["CorrelationId"] = Guid.NewGuid().ToString();

// later middleware (or controller) reads it
var correlationId = context.Items["CorrelationId"] as string;

Common bugs

  • Random 401s on authenticated users — UseAuthorization registered before UseAuthentication
  • Browser CORS fails, Postman works — UseCors registered after UseAuthentication
  • Unhandled exceptions return generic 500 — UseExceptionHandler not registered first
  • ObjectDisposedException on second request — scoped service injected into convention-based middleware (captive dependency)
  • Custom middleware never runs — app.Run() registered before it (terminal, skips everything downstream)
  • IMiddleware not resolved at runtime — missing builder.Services.AddScoped<MyMiddleware>() registration

Key takeaways

  • Middleware is a component in the HTTP pipeline that can inspect, modify, or stop a request before it reaches your endpoint.
  • Order matters — wrong order fails silently. ExceptionHandler first, CORS before Authentication, Authentication before Authorization.
  • Convention-based middleware is singleton — safe with ILogger and IConfiguration, breaks with DbContext.
  • IMiddleware resolves per request — use it when you need scoped services in the constructor.
  • Short-circuit by not calling next() — the middleware then writes the response itself.

--

--

Anchalmehandiratta
Anchalmehandiratta

Written by Anchalmehandiratta

Backend engineer. I write about .NET and databases — mostly the parts I learned by breaking them first.