ASP.NET Core Middleware Explained — Pipeline, Order & Custom Middleware
Every web framework needs a way to handle the messy, repetitive work that surrounds every request — logging it, authenticating the caller, compressing the response, catching exceptions. Without a clean mechanism for this, you'd be copy-pasting the same authentication check into every single controller action. That's not just tedious; it's a reliability disaster. ASP.NET Core solves this with a first-class middleware pipeline that's fast, composable, and completely in your control.
The problem middleware solves is cross-cutting concerns. Authentication doesn't belong to any one feature — it belongs to all of them. The same goes for logging, CORS headers, rate limiting, and exception handling. Middleware lets you write that logic once, plug it into the pipeline in the right place, and trust that it runs for every request that flows through your app. The pipeline model also means concerns are layered cleanly: outer middleware wraps inner middleware, so you can handle exceptions around everything, authenticate before authorisation, and authorise before routing — all without tangling those concerns together.
By the end of this article you'll be able to draw the ASP.NET Core request pipeline from memory, explain why order matters (with a concrete example of what breaks when you get it wrong), write a custom middleware class with proper dependency injection, and use the three ways to register middleware — UseMiddleware, Use, and Map — choosing the right tool for each situation.
How the ASP.NET Core Request Pipeline Actually Works
The pipeline is a linked chain of delegates. When a request arrives, ASP.NET Core calls the first delegate. That delegate can do work, then call 'next()' to invoke the next delegate in the chain, then do more work after next() returns. This means every piece of middleware has two opportunities to act: once on the way in (before calling next) and once on the way out (after next returns). That's the key mental model — it's not a one-way conveyor belt, it's a stack of nested function calls.
The chain terminates at a 'terminal middleware' — something like UseEndpoints — that handles the request and writes a response without calling next. If no terminal middleware matches, ASP.NET Core returns a 404.
Each middleware is registered in Program.cs using extension methods on WebApplication. The order you call those methods is the order they execute. This isn't a detail — it's the most important rule in ASP.NET Core middleware. Authentication must come before Authorisation. Exception handling must wrap everything else. We'll see exactly what breaks when that order is wrong in the Gotchas section.
// Program.cs — stripped-down app to visualise the two-phase pipeline // Run this and watch the console output to see requests flow IN and OUT var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); // Middleware A — outermost layer (first in, last out) app.Use(async (context, next) => { Console.WriteLine("[A] Incoming — path: " + context.Request.Path); await next(context); // hand control to the next middleware // This line runs AFTER the inner middlewares have all finished Console.WriteLine("[A] Outgoing — status: " + context.Response.StatusCode); }); // Middleware B — middle layer app.Use(async (context, next) => { Console.WriteLine("[B] Incoming"); await next(context); Console.WriteLine("[B] Outgoing"); }); // Terminal middleware — writes the response, does NOT call next app.Run(async context => { Console.WriteLine("[Terminal] Writing response"); context.Response.StatusCode = 200; await context.Response.WriteAsync("Hello from the pipeline!"); }); app.Run();
[B] Incoming
[Terminal] Writing response
[B] Outgoing
[A] Outgoing — status: 200
Writing a Real Custom Middleware Class (With Dependency Injection)
Inline lambdas with app.Use() are fine for trivial cases, but anything non-trivial should be a dedicated class. A middleware class gives you a testable unit, proper constructor injection, and a home for the logic that doesn't clutter Program.cs.
The convention ASP.NET Core uses for middleware classes is simple: your class needs a constructor that accepts a RequestDelegate (the next middleware), and an InvokeAsync method that accepts HttpContext. That's the entire contract. You don't implement any interface — the framework discovers the method by convention.
Scoped services can't be injected via the constructor because middleware is instantiated once (singleton lifetime). Instead, inject scoped services as parameters of InvokeAsync — the framework handles that automatically. This is a common interview question and a common production bug. The example below builds a request-timing middleware that logs how long each request takes, which is something almost every production app needs.
// RequestTimingMiddleware.cs // Measures how long each request takes and logs it. // ILogger<T> is a singleton-safe service — safe to inject in constructor. // A scoped service like a DbContext would go on InvokeAsync instead. using System.Diagnostics; public class RequestTimingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<RequestTimingMiddleware> _logger; // Constructor injection: only use singleton or transient services here public RequestTimingMiddleware( RequestDelegate next, ILogger<RequestTimingMiddleware> logger) { _next = next; _logger = logger; } // InvokeAsync is called by the framework for every request // Scoped services (e.g. AppDbContext) can be injected here as parameters public async Task InvokeAsync(HttpContext context) { var stopwatch = Stopwatch.StartNew(); // --- INCOMING: before the rest of the pipeline runs --- _logger.LogInformation( "Request started: {Method} {Path}", context.Request.Method, context.Request.Path); await _next(context); // run the rest of the pipeline // --- OUTGOING: after the response has been produced --- stopwatch.Stop(); _logger.LogInformation( "Request finished: {Method} {Path} — {StatusCode} in {ElapsedMs}ms", context.Request.Method, context.Request.Path, context.Response.StatusCode, stopwatch.ElapsedMilliseconds); } } // MiddlewareExtensions.cs // A clean extension method makes registration readable in Program.cs public static class RequestTimingMiddlewareExtensions { public static IApplicationBuilder UseRequestTiming( this IApplicationBuilder app) { return app.UseMiddleware<RequestTimingMiddleware>(); } } // Program.cs — how to wire it all up var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); var app = builder.Build(); // Register exception handling FIRST so it wraps everything app.UseExceptionHandler("/error"); // Our custom timing middleware — early so it times the full request app.UseRequestTiming(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();
Request started: GET /weatherforecast
info: RequestTimingMiddleware[0]
Request finished: GET /weatherforecast — 200 in 42ms
Map, Use, and Run — Choosing the Right Registration Method
ASP.NET Core gives you three core extension methods for building the pipeline, and each has a specific job. Mixing them up is a common source of subtle bugs.
'app.Use()' is the standard building block. It receives the HttpContext and a delegate to the next middleware. You call next to pass control forward. Use it for any middleware that should participate in both the incoming and outgoing phases.
'app.Run()' registers a terminal middleware — it never calls next because it's the end of the line. If you use Run and then register more middleware after it, those later registrations are silently ignored. This surprises a lot of developers.
'app.Map()' branches the pipeline based on the request path. Requests matching the path go down the branch; everything else continues on the main pipeline. It's perfect for health check endpoints, admin sections, or versioned API branches that need completely different middleware stacks. There's also 'MapWhen()' for branching on arbitrary conditions, like a header value or query string.
// Program.cs — demonstrating Use, Run, and Map in a single app var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); // MAP: branch the pipeline for /health — nothing else runs for this path app.Map("/health", healthApp => { // This Run only applies inside the /health branch healthApp.Run(async context => { // Lightweight health check — no auth, no logging overhead context.Response.ContentType = "application/json"; await context.Response.WriteAsync("{\"status\": \"healthy\"}"); }); }); // USE: add a header to every response NOT handled by the /health branch app.Use(async (context, next) => { // Runs on the way out — safe to set headers here before flush await next(context); context.Response.Headers.Append("X-Powered-By", "TheCodeForge"); }); // MAPWHEN: branch based on a condition — here, requests with a specific header app.MapWhen( context => context.Request.Headers.ContainsKey("X-Internal-Request"), internalApp => { internalApp.Run(async context => { await context.Response.WriteAsync("Internal route — restricted access"); }); }); // RUN: terminal middleware for all remaining requests // IMPORTANT: anything registered after this is NEVER reached app.Run(async context => { await context.Response.WriteAsync("Main application response"); }); // This middleware is DEAD CODE — app.Run above is terminal // The framework silently ignores it. Don't do this. app.Use(async (context, next) => { Console.WriteLine("This never executes!"); await next(context); }); app.Run(); // starts the web server (different Run — on WebApplication, not IApplicationBuilder)
GET /anything-else → "Main application response" + header X-Powered-By: TheCodeForge
GET / (X-Internal) → "Internal route — restricted access"
Middleware Order in Practice — The Correct ASP.NET Core Pipeline
The single most confusing thing about ASP.NET Core middleware for developers coming from other frameworks is that order is everything, and it's silent about it. There's no error if you put UseAuthorization before UseAuthentication — it just doesn't work. You get 401s or 403s that seem random until you understand the pipeline.
Microsoft documents a recommended order and it exists for good reason. Exception handling must wrap everything to catch exceptions from routing, authentication, and your own code. HTTPS redirection should happen before static files so you don't serve static assets over HTTP. Authentication must happen before Authorisation because Authorisation reads the identity that Authentication establishes. Routing must happen before Authorisation so the framework knows which endpoint's policies to check.
The table below compares the correct order against a common incorrect ordering and shows exactly what symptom you'd see. Once you understand the 'why' behind each position, the order becomes easy to remember — it's not arbitrary, it's causal.
// Program.cs — the recommended middleware order for a production API // Each comment explains WHY it's in this position, not just WHAT it does var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication().AddJwtBearer(); builder.Services.AddAuthorization(); builder.Services.AddControllers(); var app = builder.Build(); // 1. EXCEPTION HANDLER — outermost layer, catches exceptions from everything below // In development, use the developer exception page for stack traces instead if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); else app.UseExceptionHandler("/error"); // 2. HSTS + HTTPS REDIRECTION — security headers before any content is served app.UseHsts(); app.UseHttpsRedirection(); // 3. STATIC FILES — served before routing to avoid routing overhead for assets // Static files are terminal for matched paths — they never hit your controllers app.UseStaticFiles(); // 4. ROUTING — parses the URL and selects the endpoint // Must come before UseAuthentication so endpoint metadata is available // (used by some auth handlers to check endpoint-level attributes) app.UseRouting(); // 5. CORS — must come after UseRouting, before UseAuthentication // Preflight OPTIONS requests need CORS headers before auth checks app.UseCors("AllowFrontend"); // 6. AUTHENTICATION — reads the token/cookie and builds the ClaimsPrincipal // MUST come before UseAuthorization app.UseAuthentication(); // 7. AUTHORISATION — checks if the established identity has permission // Relies on UseAuthentication having already run app.UseAuthorization(); // 8. ENDPOINT EXECUTION — actually runs your controller actions / minimal API handlers app.MapControllers(); app.Run();
GET /api/secure (valid JWT) → 200 OK
GET /api/secure (no JWT) → 401 Unauthorized
GET /index.html → 200 OK (served from wwwroot, never hits routing)
GET /throws-exception → 500 with ProblemDetails JSON (caught by UseExceptionHandler)
| Aspect | app.Use() | app.Run() | app.Map() |
|---|---|---|---|
| Calls next middleware | Yes — you call next(context) explicitly | No — terminal, never calls next | No — branches to a sub-pipeline |
| Typical use case | Logging, auth, headers, timing | Fallback 404 handler, simple endpoints | Health checks, admin section, API versions |
| Sees outgoing response | Yes — code after await next() runs on the way out | No — it IS the response | Only within the branch |
| Silent gotcha | Forgetting to call next() accidentally terminates the pipeline | Middleware registered after it is silently ignored | The branch is completely isolated — main pipeline middleware doesn't run in it |
| Scoped DI services | Inject via InvokeAsync parameter (class-based) | N/A for inline lambdas; class-based same rule applies | Same rules apply within the branch |
| Testability | High — extract to class, inject ILogger etc. | Low for inline lambdas | High — branch pipeline is independently testable |
🎯 Key Takeaways
- The pipeline is a two-phase stack, not a one-way conveyor — every middleware that calls next() gets to act on both the incoming request (before next) and the outgoing response (after next). This is how UseExceptionHandler and timing middleware work.
- Order is everything and it's silent — UseAuthorization before UseAuthentication compiles and starts fine but returns 401 for every authenticated request. The recommended order exists because each layer depends on the layer above it having already run.
- Scoped services belong on InvokeAsync parameters, not constructors — middleware instances live for the lifetime of the app (singleton-ish), so injecting a scoped DbContext into the constructor causes a scope capture bug. The InvokeAsync parameter injection is the correct, framework-supported pattern.
- app.Run() silently swallows everything registered after it — it's terminal. If you're debugging middleware that seems to never run, check whether an app.Run() call appears before it in Program.cs.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Putting UseAuthorization before UseAuthentication — Symptom: every request returns 401 even with a valid JWT or cookie, regardless of the [Authorize] policy. Fix: Always place UseAuthentication() immediately before UseAuthorization(). Authentication sets up context.User; Authorisation reads it. Reversing them means Authorisation always sees an unauthenticated identity.
- ✕Mistake 2: Injecting a scoped service (e.g. DbContext) into the middleware constructor — Symptom: InvalidOperationException at startup saying 'Cannot consume scoped service from singleton', or worse, no exception but a shared DbContext across requests causing data corruption in production. Fix: Add the scoped service as a parameter on InvokeAsync(HttpContext context, MyDbContext db) — the framework resolves it from the correct per-request scope automatically.
- ✕Mistake 3: Registering middleware after app.Run() and wondering why it never executes — Symptom: middleware registered after app.Run() is completely silently ignored. No warning, no exception. Fix: Remember that app.Run() is terminal — it never calls next. Any middleware that should run for all requests must be registered before app.Run(). Use app.Use() for non-terminal middleware and reserve app.Run() for the very last handler only.
Interview Questions on This Topic
- QCan you explain the ASP.NET Core request pipeline and why middleware order matters? Give a concrete example of what breaks when the order is wrong.
- QHow does dependency injection work differently in a middleware constructor versus the InvokeAsync method, and why does that difference exist?
- QWhat's the difference between app.Use(), app.Run(), and app.Map()? If I register middleware after app.Run(), what happens and why?
Frequently Asked Questions
What is middleware in ASP.NET Core and how is it different from filters?
Middleware operates at the HTTP pipeline level — it sees every request before routing even happens, and it can short-circuit the pipeline entirely. Filters (Action filters, Exception filters etc.) operate within the MVC layer and only run for requests that reach a controller action. Use middleware for cross-cutting concerns that apply to all requests (logging, authentication, CORS). Use filters for MVC-specific logic like validating model state or handling exceptions inside controller actions.
Can I run async code inside middleware?
Yes — and you should. InvokeAsync returns a Task, and you use async/await throughout. The critical rule is to await the call to next(context) rather than calling it synchronously. Never call next(context).Wait() or .Result — that causes thread starvation under load by blocking the thread pool.
How do I conditionally skip a middleware for certain routes?
The cleanest approach is app.MapWhen() to branch the pipeline based on the path or any condition, directing matched requests to a sub-pipeline that doesn't include the middleware you want to skip. Alternatively, inside your middleware you can check context.Request.Path and call next() immediately without doing your work. For endpoint-specific exclusions, some middleware (like UseAuthorization) respects the [AllowAnonymous] attribute, which is a cleaner pattern than path checking in middleware code.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.