Middleware in .NET Explained: How the Pipeline Actually Works (and Common Mistakes)
Understand how the ASP.NET Core middleware pipeline works under the hood, learn to write custom middleware correctly, and avoid the mistakes that cause silent bugs in production.
You’ve called app.UseAuthentication() and app.UseAuthorization() a hundred times. But do you actually know what happens when a request hits your pipeline? And more importantly, do you know what happens when you put them in the wrong order?
Middleware is the backbone of every ASP.NET Core application. Every request passes through it. Every response passes through it. Authentication, logging, exception handling, CORS, routing—all middleware. Yet most developers treat it as boilerplate they copy from a tutorial without understanding the execution model.
In this article, we’ll break down how the middleware pipeline actually works, write custom middleware the right way, and fix the most common mistakes I see in code reviews.
How the Pipeline Works
The ASP.NET Core middleware pipeline is a chain of components, each wrapping the next. Think of it like Russian nesting dolls: each middleware gets a chance to process the request going in, then process the response going out.
Request → [Logging] → [Auth] → [Routing] → [Endpoint]
↓
Response ← [Logging] ← [Auth] ← [Routing] ← [Result]
Each middleware component can:
- Do something before passing the request to the next middleware
- Call
next()to pass control to the next middleware - Do something after the next middleware has returned
- Short-circuit the pipeline by not calling
next()
Here’s the simplest possible middleware:
app.Use(async (context, next) =>
{
// Before: runs on the way IN
Console.WriteLine("Before next middleware");
await next(context); // Pass to next middleware
// After: runs on the way OUT
Console.WriteLine("After next middleware");
});
This dual-phase execution is what makes middleware powerful—and what makes ordering critical.
Order Matters: A Real Example
Consider this Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Now watch what happens if you swap authentication and authorization:
// ❌ WRONG ORDER
app.UseAuthorization(); // Checks auth... but no identity exists yet!
app.UseAuthentication(); // Sets identity... too late
Authorization runs first, finds no authenticated user, and rejects the request. Authentication never gets a chance to validate the token. The request fails with a 401, and you’ll spend hours debugging.
The recommended order is:
app.UseExceptionHandler(); // 1. Catch errors from everything below
app.UseHsts(); // 2. Security headers
app.UseHttpsRedirection(); // 3. Redirect HTTP to HTTPS
app.UseStaticFiles(); // 4. Serve static files (no auth needed)
app.UseRouting(); // 5. Determine which endpoint matches
app.UseCors(); // 6. CORS (after routing, before auth)
app.UseAuthentication(); // 7. Who are you?
app.UseAuthorization(); // 8. Are you allowed?
app.MapControllers(); // 9. Execute endpoint
This isn’t arbitrary—each step depends on the one before it. Routing needs to happen before CORS so the correct policy can be selected. Authentication needs to happen before authorization so there’s an identity to check against.
The Three Ways to Write Middleware
1. Inline Middleware (Quick and Dirty)
Use app.Use() for simple, one-off middleware:
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
context.Response.OnStarting(() =>
{
stopwatch.Stop();
context.Response.Headers.Append(
"X-Response-Time", $"{stopwatch.ElapsedMilliseconds}ms");
return Task.CompletedTask;
});
await next(context);
});
Good for prototyping. Not great for production code—no dependency injection, no testability.
Note: We use
OnStarting()here to safely add the response header. Modifying headers afternext()returns can throw if the response has already started streaming—we’ll cover this in detail in the Common Mistakes section.
2. Convention-Based Middleware (The Standard Way)
The most common pattern. Create a class with an InvokeAsync method:
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
await _next(context);
stopwatch.Stop();
var elapsed = stopwatch.ElapsedMilliseconds;
_logger.LogInformation(
"{Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
context.Request.Method,
context.Request.Path,
elapsed,
context.Response.StatusCode);
}
}
// Register it
app.UseMiddleware<RequestTimingMiddleware>();
Convention-based middleware is created once at application startup. The constructor runs once, and the same instance handles all requests. This means:
- Constructor dependencies are singletons (even if registered as scoped or transient)
InvokeAsynccan inject scoped services as parameters- Don’t store request-specific state in fields
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
public RequestTimingMiddleware(RequestDelegate next)
{
_next = next;
}
// Scoped services injected here, not in constructor
public async Task InvokeAsync(HttpContext context, IOrderService orderService)
{
// orderService is properly scoped per request
await _next(context);
}
}
3. Factory-Based Middleware (IMiddleware)
For middleware that needs scoped or transient lifetime:
public class TransactionMiddleware : IMiddleware
{
private readonly AppDbContext _dbContext;
// This constructor runs PER REQUEST (not once like convention-based)
public TransactionMiddleware(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
await next(context);
if (context.Response.StatusCode < 400)
{
await transaction.CommitAsync();
}
else
{
await transaction.RollbackAsync();
}
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
// Must register in DI
builder.Services.AddScoped<TransactionMiddleware>();
// Then use it
app.UseMiddleware<TransactionMiddleware>();
The key difference: IMiddleware is resolved from DI per request, so it respects the service lifetime you register. Convention-based middleware is a singleton regardless.
Short-Circuiting the Pipeline
Sometimes you want to stop the pipeline early—no need to call the next middleware. This is called short-circuiting:
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private const string ApiKeyHeader = "X-API-Key";
public ApiKeyMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IConfiguration config)
{
if (!context.Request.Headers.TryGetValue(ApiKeyHeader, out var extractedApiKey))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { error = "API key is missing" });
return; // Short-circuit: don't call _next
}
var validApiKey = config["ApiKey"];
if (!string.Equals(extractedApiKey, validApiKey, StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { error = "Invalid API key" });
return; // Short-circuit
}
await _next(context); // API key is valid, continue
}
}
When you don’t call _next, no downstream middleware executes. The response flows back through the upstream middleware that already ran.
Terminal Middleware with app.Run()
app.Run() is a special case—it never calls next() and always short-circuits:
app.Run(async context =>
{
await context.Response.WriteAsync("Hello, World!");
});
// ⚠️ Nothing after app.Run() will execute
app.UseAuthorization(); // This never runs!
Use app.Run() only as the last middleware in the pipeline, or for catch-all fallback endpoints.
Conditional Middleware with app.UseWhen() and app.MapWhen()
Apply middleware only to certain requests:
// UseWhen: applies middleware conditionally, rejoins main pipeline afterward
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/api"),
appBuilder =>
{
appBuilder.UseMiddleware<ApiKeyMiddleware>();
}
);
// MapWhen: branches the pipeline (terminal - doesn't rejoin)
app.MapWhen(
context => context.Request.Path.StartsWithSegments("/health"),
appBuilder =>
{
appBuilder.Run(async context =>
{
context.Response.StatusCode = 200;
await context.Response.WriteAsync("OK");
});
}
);
The difference matters: UseWhen rejoins the main pipeline after the branch (unless the branch contains terminal middleware), while MapWhen creates a completely separate fork.
Extension Methods: Making Middleware Clean
Don’t make your consumers write app.UseMiddleware<YourMiddleware>(). Write an extension method:
public static class MiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestTimingMiddleware>();
}
public static IApplicationBuilder UseApiKey(this IApplicationBuilder builder)
{
return builder.UseMiddleware<ApiKeyMiddleware>();
}
}
// Clean usage in Program.cs
app.UseRequestTiming();
app.UseApiKey();
This is the pattern that all built-in middleware uses (UseAuthentication(), UseCors(), etc.).
Real-World Example: Global Exception Handling Middleware
This is one of the most useful custom middleware patterns:
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Validation error on {Path}", context.Request.Path);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new
{
error = "Validation failed",
details = ex.Errors.Select(e => e.ErrorMessage)
});
}
catch (NotFoundException ex)
{
_logger.LogWarning("Resource not found: {Message}", ex.Message);
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new
{
error = ex.Message
});
}
catch (UnauthorizedAccessException)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
error = "Access denied"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception on {Method} {Path}",
context.Request.Method, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
error = "An unexpected error occurred",
traceId = Activity.Current?.Id ?? context.TraceIdentifier
});
}
}
}
Register it first in the pipeline so it catches exceptions from every other middleware:
app.UseMiddleware<GlobalExceptionMiddleware>(); // First!
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Real-World Example: Request/Response Logging Middleware
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
public RequestResponseLoggingMiddleware(RequestDelegate next,
ILogger<RequestResponseLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Log request
_logger.LogInformation(
"HTTP {Method} {Path}{QueryString} from {IP}",
context.Request.Method,
context.Request.Path,
context.Request.QueryString,
context.Connection.RemoteIpAddress);
await _next(context);
// Log response
_logger.LogInformation(
"HTTP {Method} {Path} responded {StatusCode} in {ContentLength} bytes",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
context.Response.ContentLength);
}
}
Common Mistakes
Mistake 1: Modifying the Response After next()
// ❌ BROKEN: response may already be sent
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
// Headers might already be sent to the client!
context.Response.Headers.Append("X-Custom", "value"); // Can throw
context.Response.StatusCode = 200; // Too late
}
Once the response body starts being written, you can’t modify headers or the status code. The fix is to use context.Response.OnStarting():
// ✅ CORRECT: register callback before response starts
public async Task InvokeAsync(HttpContext context)
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Append("X-Custom", "value");
return Task.CompletedTask;
});
await _next(context);
}
Mistake 2: Forgetting to Await next()
// ❌ WRONG: not awaiting next
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
_next(context); // Missing await!
stopwatch.Stop();
// This runs IMMEDIATELY, not after the pipeline completes
_logger.LogInformation("Request took {Ms}ms", stopwatch.ElapsedMilliseconds);
}
Without await, your “after” code runs before the rest of the pipeline finishes. You’ll measure 0ms for every request and wonder why your logging is broken.
Mistake 3: Storing Request State in Middleware Fields
// ❌ WRONG: convention-based middleware is a singleton!
public class BadMiddleware
{
private readonly RequestDelegate _next;
private string _currentUser; // Shared across ALL requests!
public BadMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
_currentUser = context.User?.Identity?.Name; // Race condition!
await _next(context);
Console.WriteLine($"User: {_currentUser}"); // Wrong user!
}
}
Convention-based middleware is a singleton. Fields are shared across all concurrent requests. Use local variables instead:
// ✅ CORRECT: local variable, scoped to the request
public async Task InvokeAsync(HttpContext context)
{
var currentUser = context.User?.Identity?.Name; // Local, safe
await _next(context);
Console.WriteLine($"User: {currentUser}");
}
Mistake 4: Injecting Scoped Services in the Constructor
// ❌ WRONG: DbContext is scoped, middleware is singleton
public class BadMiddleware
{
private readonly RequestDelegate _next;
private readonly AppDbContext _dbContext; // Captive dependency!
public BadMiddleware(RequestDelegate next, AppDbContext dbContext)
{
_next = next;
_dbContext = dbContext; // Same instance for ALL requests
}
}
This is the “captive dependency” problem. A singleton holds a reference to a scoped service, which means the same DbContext instance is reused for every request—causing threading issues and stale data.
Fix: inject scoped services in InvokeAsync, not the constructor:
// ✅ CORRECT: inject in InvokeAsync
public class GoodMiddleware
{
private readonly RequestDelegate _next;
public GoodMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, AppDbContext dbContext)
{
// dbContext is properly scoped per request
await _next(context);
}
}
Mistake 5: Writing to the Response Body AND Calling next()
// ❌ WRONG: writes to response, then continues pipeline
public async Task InvokeAsync(HttpContext context)
{
if (someCondition)
{
await context.Response.WriteAsync("Error!");
// Missing return! Falls through to next middleware
}
await _next(context); // Pipeline tries to write to already-started response
}
If you write to the response body, you must return to short-circuit. Otherwise the next middleware might try to write again, causing a Response has already started exception.
// ✅ CORRECT: return after writing
public async Task InvokeAsync(HttpContext context)
{
if (someCondition)
{
await context.Response.WriteAsync("Error!");
return; // Short-circuit
}
await _next(context);
}
Mistake 6: Calling next() Multiple Times
// ❌ WRONG: calling next twice
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
if (context.Response.StatusCode == 404)
{
context.Response.Clear();
await _next(context); // Double execution!
}
}
Calling _next() twice means the entire downstream pipeline runs twice. This can cause duplicate database writes, double logging, and corrupted response streams. The pipeline is designed for a single pass.
Middleware vs Filters: When to Use Which
ASP.NET Core also has action filters, which run inside the MVC/routing pipeline. Here’s when to use each:
Use middleware when:
- You need to run logic for every request (including static files)
- You need to run before routing
- You’re handling cross-cutting concerns (logging, timing, CORS, security headers)
- You want to short-circuit before MVC even runs
Use filters when:
- You need access to
ActionContextor model binding results - Logic only applies to controller actions
- You need to run before/after specific action methods
- You need access to the action’s parameters or result
Request → [Middleware Pipeline] → [Routing] → [Filters] → [Action Method]
Middleware wraps the entire pipeline. Filters wrap only the controller action execution.
Conclusion
Middleware is one of those things that’s easy to use and easy to misuse. The pipeline model is simple in concept but has subtle behaviors that catch developers off guard.
Key takeaways:
- Order matters: authentication before authorization, exception handling first, static files before routing
- Convention-based middleware is a singleton: don’t store request state in fields, don’t inject scoped services in the constructor
- Always await next(): forgetting this breaks the pipeline silently
- Short-circuit correctly: if you write to the response, return immediately
- Use OnStarting() for response headers: don’t modify headers after
next()returns - Extension methods make middleware clean: follow the
UseXxx()pattern
Understand the pipeline, respect the ordering, and your middleware will work exactly as intended.