Reversed Auth Middleware Order — Silent 401 in ASP.NET Core
A valid JWT yields 401 when UseAuthorization runs before UseAuthentication.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- ASP.NET Core middleware is a chain of delegates that process HTTP requests and responses in a two-phase pipeline.
- Each middleware can inspect, modify, or short-circuit the request/response.
- Order is critical: each middleware depends on the layer above having run.
- Performance overhead is minimal (sub-millisecond per middleware) but incorrect order silently breaks security.
- Production insight: Putting UseAuthorization before UseAuthentication returns 401 on every authenticated request.
- Biggest mistake: Injecting a scoped DbContext into the middleware constructor instead of InvokeAsync parameters.
Middleware in ASP.NET Core is the backbone of the request pipeline — a sequence of delegates that process every HTTP request and response as they flow through your application. Each middleware component can inspect, modify, or short-circuit the pipeline by either calling the next delegate (passing the request downstream) or returning early (e.g., sending a 401 Unauthorized response).
This composable architecture lets you layer cross-cutting concerns like authentication, logging, compression, and CORS without coupling them to your application logic. The order you register middleware in Program.cs (or Startup.Configure) is the exact order requests traverse — a reversed order means authentication runs after authorization, or error handling runs before logging, leading to silent failures like a 401 that never reaches your custom middleware because an earlier component already short-circuited the pipeline.
In practice, the pipeline is a linked list of Func<HttpContext, RequestDelegate, Task> delegates, where each middleware either calls next(context) to continue or returns a response directly. Common pitfalls include placing UseAuthentication() after UseAuthorization() (which silently returns 401 before authentication can set the user principal), or registering UseExceptionHandler() after middleware that throws exceptions (so the error handler never catches them).
The Map, Use, and Run methods control branching and termination: Use chains delegates, Run terminates the pipeline (no next), and Map branches based on request path. Understanding this order is critical because ASP.NET Core's middleware is not declarative — it's procedural, and every registration order mistake manifests as a runtime bug that's hard to trace.
When you need to short-circuit the pipeline — for example, returning a 401 from a custom authentication middleware — you simply omit the await next(context) call and write the response directly. This is how ASP.NET Core's built-in authentication middleware works: if the user isn't authenticated, it sets a 401 status and returns without calling the next delegate.
The silent 401 problem arises when middleware that should run before authentication (like logging or request validation) is registered after it, so the request never reaches those components. Debugging this requires inspecting the middleware order in Program.cs and understanding that the pipeline is a stack — the first registered middleware is the outermost layer, and the last registered is the innermost (closest to your endpoint).
Tools like the app.UseMiddleware<T>() overload with explicit ordering or the Microsoft.AspNetCore.Diagnostics package's DeveloperExceptionPage middleware can help visualize the pipeline, but the fix is always reordering registrations to match the logical flow: error handling first, then authentication, authorization, routing, and finally your custom middleware.
Imagine every HTTP request is a letter arriving at a busy office. Before the letter reaches the CEO (your controller), it passes through a chain of desks — the receptionist checks if it's addressed correctly, the security guard checks for a valid ID badge, the assistant stamps it with the time it arrived. Each desk can either pass the letter forward, send it straight back, or even modify it before passing it on. That chain of desks is exactly what ASP.NET Core middleware is. Each piece of middleware is one desk in that chain — it inspects the request, optionally does something to it, and decides whether to pass it to the next desk or short-circuit and send a response right back.
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.
What Middleware in ASP.NET Core Actually Does
Middleware in ASP.NET Core is software assembled into an application pipeline to handle requests and responses. Each component chooses whether to pass the request to the next component and can perform actions before and after the next component in the pipeline. This is the core mechanic: a chain of delegates where each middleware can short-circuit the pipeline by not calling the next delegate.
In practice, middleware is registered in order in the Program.cs file using extension methods like UseAuthorization(), UseAuthentication(), and UseCors(). The order matters critically because each middleware can modify the request or response context. For example, authentication middleware must run before authorization middleware — otherwise, authorization checks will see an unauthenticated user and fail silently, returning a 401 without any diagnostic.
You use middleware to handle cross-cutting concerns like logging, exception handling, authentication, authorization, response compression, and static files. In real systems, getting the order wrong is the most common source of silent 401 errors that baffle teams. The rule is: CORS, then Authentication, then Authorization, then your custom middleware — in that exact sequence.
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();
next() runs after all inner middleware have completed.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();
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)
Map() when a section of your app needs genuinely different middleware. A /metrics endpoint, for example, shouldn't go through authentication middleware — it should have its own IP-allow-list check. Map lets you build a completely separate pipeline branch rather than cluttering your main pipeline with conditional logic.Run() is terminal—any middleware registered after it is silently ignored.Run() and wondering why it doesn't execute.Run() for the final handler; use app.Use() for middleware that should continue.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();
Short-Circuiting the Pipeline: When to Return Early Without Calling next()
Middleware isn't required to call next(). If it sets a response and returns without calling next, the pipeline short-circuits — inner middleware never runs, and outer middleware's post-next code also doesn't execute. This is exactly what authentication middleware does when a token is invalid: it returns 401 immediately without passing the request to your controllers.
Short-circuiting is powerful for early rejection patterns: maintenance mode checks, IP whitelisting, request size limits, and authentication failures all legitimately stop the pipeline early. But there's a trade-off: if you have timing or logging middleware registered before the short-circuiting middleware, their outgoing-phase code won't run. That means no log entry for rejected authentication attempts unless the short-circuiting middleware handles logging itself.
The decision to short-circuit should be deliberate. Use it when you know the request cannot possibly be fulfilled. But if you only want to modify the response (e.g., add a header) without affecting inner pipeline logic, always call next() first and modify the response after.
// Program.cs — demonstrating short-circuiting middleware var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); // Timing middleware — logs start/end app.Use(async (context, next) => { Console.WriteLine("Timing: Incoming"); var sw = Stopwatch.StartNew(); await next(context); sw.Stop(); Console.WriteLine($"Timing: {sw.ElapsedMilliseconds}ms"); }); // Short-circuit middleware: block requests from certain IPs app.Use(async (context, next) => { var blockedIPs = new[] { "192.168.1.100" }; if (blockedIPs.Contains(context.Connection.RemoteIpAddress?.ToString())) { context.Response.StatusCode = 403; await context.Response.WriteAsync("Blocked"); return; // short-circuit — no next() call } await next(context); // pass through for allowed IPs }); app.Run(async context => { await context.Response.WriteAsync("Hello from the app!"); }); app.Run();
next()) never executes for requests that are short-circuited. Your monitors will miss those requests. To capture them, either: (1) register logging middleware after the short-circuit middleware, or (2) have the short-circuit middleware manually emit its own log entry before returning.next() to continue pipeline, or skip to short-circuit.Middleware Dependencies — Singleton, Scoped, Transient: Choose or Lose
Middleware constructors are singleton by default. They live for the lifetime of the application. That means any scoped service injected via the constructor is effectively a singleton — you'll get the same instance for every request.
This is a production trap. You register a scoped IInvoiceContext, inject it into middleware, and wonder why your second request has stale data. Because it does. The DI container resolves scoped services at startup when the middleware is instantiated, not per request.
InvokeAsync takes a scoped parameter directly from DI for each request. Use that pattern. Never inject scoped services into the constructor. Transient services that carry state? Same rule. Constructor is for singletons only. If you need a fresh instance of something per request, pull it in the method signature.
Here's the litmus test: if your middleware touches a database, reads user-specific data, or writes to a scoped cache, it belongs in InvokeAsync, not the constructor. If it's static configuration or a logger? Fine in the constructor. Everything else is a bug waiting to happen.
// io.thecodeforge — csharp tutorial public class InvoiceAuthMiddleware { private readonly RequestDelegate _next; private readonly ILogger<InvoiceAuthMiddleware> _logger; // singleton: OK public InvoiceAuthMiddleware(RequestDelegate next, ILogger<InvoiceAuthMiddleware> logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context, IInvoiceContext invoiceContext) { // invoiceContext is scoped — resolved fresh per request from DI var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault(); if (tenantId != null) { invoiceContext.SetTenant(tenantId); } await _next(context); } }
Branch the Pipeline with Map — Routes, Not Religion
Not every path through your app needs the same middleware. Logging headers? Yes, everywhere. Checking authentication for the admin panel? Only under /admin. Rendering static files? Only for paths that map to actual files.
Branching middleware pipelines is how you avoid paying for work you don't need. Map splits the pipeline based on a path match. When the request hits /healthz, you don't need rate limiting, auth, or request logging — just return 200 and get out.
MapWhen gives you more control — predicate on anything: query string, header value, random coin flip. But don't get cute. Branching should reflect explicit app boundaries: admin area, API version, health probes. Use Map for static path branches, MapWhen only when you absolutely need conditional logic.
Every branch is a fresh pipeline. That means if you branch after CORS and before auth, the branch doesn't get auth. Plan your branch points carefully. The middleware order rules don't change per branch — each branch is independent. Common pitfall: branching early and forgetting to re-add error handling. Your health check gets no exception handler. One unhandled exception later, you remember.
If a branch doesn't call _next, it's terminal. That's the whole point.
// io.thecodeforge — csharp tutorial var app = builder.Build(); app.UseExceptionHandler("/error"); // global — runs before any branch app.Map("/healthz", healthApp => { healthApp.Run(async context => { context.Response.StatusCode = 200; await context.Response.WriteAsync("Healthy"); }); }); app.MapWhen( ctx => ctx.Request.Headers["X-Debug"].Any(), debugApp => { debugApp.Use(async (context, next) => { // debug middleware: only on requests with X-Debug header var stopwatch = Stopwatch.StartNew(); await next(); stopwatch.Stop(); context.Response.Headers["X-Elapsed-Ms"] = stopwatch.ElapsedMilliseconds.ToString(); }); }); app.Run();
Map for path-based branches — it's cleaner and the pipeline abstraction is obvious. MapWhen is for runtime conditions. If you use MapWhen for something that could be Map, you're making the team guess what the predicate does.The Silent 401: When Middleware Order Strikes at 3 AM
UseAuthorization() was placed before UseAuthentication(). The authorization middleware checked context.User, which was still an anonymous identity because authentication hadn't run yet. No exception, no warning — every request that hit an [Authorize] endpoint returned 401.UseAuthentication() immediately before UseAuthorization(). The recommended pipeline: exception handler → routing → cors → authentication → authorization → endpoints. Deployed the fix and all endpoints returned 200.- The order of middleware is causal, not decorative: authentication builds the user identity; authorization reads it. Reversing them is a silent failure.
- Always run a simple integration test after any middleware registration change — send a request with a valid token and assert the response is not 401.
- Use code analysis or a custom middleware that logs the pipeline order at startup to catch accidental reordering.
Run() call appears before it. app.Run() is terminal — everything after it is silently ignored. Ensure non-terminal middleware uses app.Use() not app.Run().UseAuthentication() must be before UseAuthorization(). Also confirm that CORS middleware is after UseRouting and before UseAuthentication.UseCors() is placed after UseRouting() and before UseAuthentication(). Preflight requests should not require authentication. Also verify the CORS policy allows the requested origin and method.curl -v -H 'Authorization: Bearer <token>' http://localhost:5000/api/secure | grep HTTPCheck Program.cs for middleware registration sequence. Use dotnet list configuration to verify appsettings.In your middleware class, change 'public MyMiddleware(RequestDelegate next, MyDbContext db)' to 'public async Task InvokeAsync(HttpContext context, MyDbContext db)'Remove the scoped parameter from constructor and keep only singleton-safe services (ILogger, IOptions).grep -n 'app\.Run\|app\.Use\|app\.Map' Program.cs to see registration orderInspect the last registration: if it's app.Run(), everything after is dead code.Use() for all non-terminal middleware, and place app.Run() only at the end with a fallback handler.| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| PipelineVisualization.cs | var builder = WebApplication.CreateBuilder(args); | How the ASP.NET Core Request Pipeline Actually Works |
| RequestTimingMiddleware.cs | using System.Diagnostics; | Writing a Real Custom Middleware Class (With Dependency Inje |
| MiddlewareRegistrationMethods.cs | var builder = WebApplication.CreateBuilder(args); | Map, Use, and Run |
| CorrectMiddlewareOrder.cs | var builder = WebApplication.CreateBuilder(args); | Middleware Order in Practice |
| ShortCircuitExample.cs | var builder = WebApplication.CreateBuilder(args); | Short-Circuiting the Pipeline |
| ScopedMiddlewareFix.cs | public class InvoiceAuthMiddleware | Middleware Dependencies |
| BranchingPipeline.cs | var app = builder.Build(); | Branch the Pipeline with Map |
Key takeaways
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.Run() silently swallows everything registered after itRun() call appears before it in Program.cs.Common mistakes to avoid
3 patternsPutting UseAuthorization before UseAuthentication
UseAuthentication() immediately before UseAuthorization(). Authentication sets up context.User; Authorisation reads it.Injecting a scoped service (e.g., DbContext) into the middleware constructor
Registering middleware after app.Run()
Run() is completely silently ignored. No warning, no exception.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
Can 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.
How does dependency injection work differently in a middleware constructor versus the InvokeAsync method, and why does that difference exist?
What's the difference between app.Use(), app.Run(), and app.Map()? If I register middleware after app.Run(), what happens and why?
Use() adds middleware that can call next to continue the pipeline — it participates in both the incoming and outgoing phases. app.Run() adds terminal middleware that never calls next; it represents the end of the pipeline for that branch. app.Map() creates a branch in the pipeline based on a request path prefix, building an independent sub-pipeline. If you register middleware after app.Run(), it is silently ignored because app.Run() is terminal — the pipeline never reaches subsequent middlewares. This is a common source of confusion because no error is raised.Frequently Asked Questions
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.
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.
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.
Create a DefaultHttpContext, set up the request properties, instantiate your middleware with a mock RequestDelegate, and call InvokeAsync. Assert on the response status code or headers. For integration tests, use WebApplicationFactory<Program> with TestServer, sending HTTP requests through the full pipeline and verifying behavior. The key is to test both the normal path (middleware calls next and the response is produced) and the short-circuit path (middleware returns without calling next).
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
That's ASP.NET. Mark it forged?
6 min read · try the examples if you haven't