Middleware and the Request Pipeline
Before you start
You need: Program.cs (Article 01).
Time: about 45 minutes, plus the practice.
Learning objective
Order a middleware pipeline correctly and diagnose, from a symptom, which piece is in the wrong place.
Topics
- What middleware is
Use,RunandMap- The correct order, and why
- Writing custom middleware
- Short-circuiting
- Branching
- Middleware versus filters
- Diagnosing order problems
What middleware is
Middleware components form a chain. Each one receives the request, may act on it, and either passes it to the next component or short-circuits and responds itself. On the way back, each sees the response.
Request → [A] → [B] → [C] → Endpoint
Response ← [A] ← [B] ← [C] ←
That symmetry is why an exception handler registered first catches exceptions from everything after it, and why a timing middleware registered first measures the whole pipeline.
app.Use(async (context, next) =>
{
// before: on the way in
await next(context);
// after: on the way out
});
await next(context) is what continues the chain. Omit it and every component after this one never runs — the request short-circuits with whatever response has been written so far, which is often a 200 with an empty body.
Use, Run and Map
// Use — may call the next component
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Request-Id", Guid.NewGuid().ToString());
await next(context);
});
// Run — terminal, never calls next
app.Run(async context =>
{
await context.Response.WriteAsync("Not found");
});
// Map — branches on a path prefix
app.Map("/health", healthApp =>
{
healthApp.Run(async context =>
{
await context.Response.WriteAsync("Healthy");
});
});
// MapWhen — branches on any predicate
app.MapWhen(context => context.Request.Query.ContainsKey("debug"), debugApp =>
{
debugApp.Run(async context => await context.Response.WriteAsync("Debug mode"));
});
// UseWhen — branches, then rejoins the main pipeline
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), apiApp =>
{
apiApp.UseMiddleware<ApiKeyMiddleware>();
});
Map and MapWhen do not rejoin the main pipeline — the branch is terminal. UseWhen does rejoin, which is what you want for conditionally adding a component.
The correct order
var app = builder.Build();
// 1. Forwarded headers — first, before anything reads scheme or host
app.UseForwardedHeaders();
// 2. Exception handling — early, to catch everything after it
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
// 3. HTTPS redirection
app.UseHttpsRedirection();
// 4. Static files — before routing, so they short-circuit early
app.UseStaticFiles();
// 5. Routing — selects the endpoint
app.UseRouting();
// 6. CORS — after routing, before auth
app.UseCors("SchoolPortal");
// 7. Authentication — who are you
app.UseAuthentication();
// 8. Authorization — may you do this
app.UseAuthorization();
// 9. Session, if used
app.UseSession();
// 10. Endpoints
app.MapControllers();
app.Run();
Order is not stylistic. Each of these placements exists for a reason:
| Rule | What breaks otherwise |
|---|---|
UseForwardedHeaders first | Wrong scheme and host behind a proxy; redirect loops |
| Exception handling early | Exceptions thrown before it are unhandled — a raw 500 with a stack trace |
| Static files before routing | Every image goes through routing and auth unnecessarily |
UseRouting before CORS and auth | The endpoint is not selected yet, so endpoint-specific policies do not apply |
UseCors after routing, before auth | Preflight OPTIONS requests fail with 401 |
| Authentication before authorization | The user is always anonymous, so every [Authorize] returns 401 |
| Endpoints last | Nothing after them runs |
The authentication-before-authorization rule is the one people hit first. UseAuthorization reads HttpContext.User, which UseAuthentication populates — reversing them means User.Identity.IsAuthenticated is always false, and the symptom is "my valid token is being rejected".
Static files short-circuit. UseStaticFiles writes the file and does not call next, which is why placing it after authentication accidentally protects your CSS — and why placing it before means static files are never authenticated. Both are sometimes what you want; know which you chose.
Custom middleware
The conventional class form:
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();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"{Method} {Path} responded {StatusCode} in {Elapsed}ms",
context.Request.Method,
context.Request.Path,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
}
}
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
{
return app.UseMiddleware<RequestTimingMiddleware>();
}
}
app.UseRequestTiming();
Two things about the lifetime, and they cause real bugs.
Middleware is a singleton. The constructor runs once, at startup. Anything injected into the constructor is captured for the application's lifetime — so injecting a scoped service there is a captive dependency:
// Wrong — the DbContext is captured forever
public RequestTimingMiddleware(RequestDelegate next, SchoolDbContext context)
// Right — scoped services are injected per request into InvokeAsync
public async Task InvokeAsync(HttpContext context, IStudentService studentService)
InvokeAsync supports method injection precisely for this. The framework resolves those parameters from the request scope on every call.
Middleware must not have mutable instance state. One instance serves every concurrent request, so a field written per request is a race condition.
For simple cases, the inline form is fine:
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Items["CorrelationId"] = correlationId;
context.Response.Headers.Append("X-Correlation-Id", correlationId);
await next(context);
});
HttpContext.Items is per-request storage — the correct place to pass a value from middleware to a controller.
Short-circuiting
app.Use(async (context, next) =>
{
if (!context.Request.Headers.ContainsKey("X-Api-Key"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { error = "API key required" });
return; // do not call next
}
await next(context);
});
Once the response has started, headers cannot be changed. Writing to the body flushes headers, and any later attempt throws:
Headers are read-only, response has already started.
This is the usual reason an exception handler fails to convert an error into a clean 500 — something downstream already began writing.
if (context.Response.HasStarted)
{
_logger.LogWarning("Cannot modify the response; it has already started.");
return;
}
Check HasStarted in any middleware that writes on the way out.
A practical set
// Correlation id for tracing a request across logs
app.Use(async (context, next) =>
{
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
using (logger.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = correlationId }))
{
context.Response.Headers.Append("X-Correlation-Id", correlationId);
await next(context);
}
});
// Security headers
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
await next(context);
});
// Tenant resolution from the authenticated user
app.Use(async (context, next) =>
{
var schoolIdClaim = context.User.FindFirst("SchoolId");
if (schoolIdClaim is not null && int.TryParse(schoolIdClaim.Value, out var schoolId))
{
context.Items["SchoolId"] = schoolId;
}
await next(context);
});
The tenant middleware must be registered after UseAuthentication, or context.User has no claims.
logger.BeginScope attaches the correlation id to every log entry written during the request — which is what makes a production log searchable.
Middleware versus filters
Both intercept requests. They operate at different levels.
| Middleware | Filters | |
|---|---|---|
| Level | Every request | MVC actions only |
| Knows the action | No | Yes |
| Access to model binding | No | Yes |
| Access to the action result | No | Yes |
| Scope | Global, or a branch | Global, controller, or action |
// Filter — has the bound model and the action context
public class ValidateModelFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(context.ModelState);
}
}
public void OnActionExecuted(ActionExecutedContext context) { }
}
Use middleware for cross-cutting concerns that apply to everything — logging, security headers, exception handling, CORS. Use filters when you need the action, the bound model or the result — validation, per-action caching, action-level auditing.
Trying to read a bound model in middleware means reading and re-buffering the request body by hand, which is a strong signal that a filter was the right tool.
Diagnosing order problems
| Symptom | Likely cause |
|---|---|
| A valid token is rejected with 401 | UseAuthorization before UseAuthentication |
Preflight OPTIONS returns 401 or 404 | UseCors missing, or after UseAuthentication |
| Exceptions produce a raw stack trace | UseExceptionHandler registered too late |
| Static files return 401 | UseStaticFiles after UseAuthentication |
| Wrong scheme in generated URLs | UseForwardedHeaders missing or not first |
| An endpoint is never reached | Something short-circuited — a middleware not calling next |
| "Headers are read-only" | Modifying the response after it started |
context.User has no claims | Reading it before UseAuthentication |
| A custom middleware runs once and never again | It is a singleton and captured a scoped dependency |
To see the pipeline actually execute, log entry and exit around each component:
app.Use(async (context, next) =>
{
logger.LogDebug("→ entering A");
await next(context);
logger.LogDebug("← leaving A");
});
Running that with three components makes the nesting concrete, and it is the fastest way to internalise the order.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Every request returns 401, even with a valid token | UseAuthorization before UseAuthentication | Swap them |
| CORS fails only on authenticated calls | UseCors after UseAuthentication | Move it before |
| CORS does nothing at all | UseCors before UseRouting | Put it after |
| Requests hang or return an empty 200 | Custom middleware never calls next() | Call it |
| The exception handler never fires | Registered too late | It must be first |
| Static files 404 | UseStaticFiles missing or after routing | Add it early |
Order is behaviour, not style. Every row here is a correct-looking Program.cs that fails, and none of them produce a message naming the real cause.
Common mistakes
UseAuthorizationbeforeUseAuthenticationUseCorsbeforeUseRouting, or afterUseAuthentication- Exception handling registered after the middleware that throws
- Forgetting
await next(context) - Injecting a scoped service into a middleware constructor
- Mutable instance state in middleware
- Modifying the response after it has started
MapwhereUseWhenwas needed, so the branch never rejoins- Middleware where a filter was the right tool
- Registering anything after
MapControllersand expecting it to run
Practice
- Write three inline middleware components that log on entry and exit. Run one request and record the output order.
- Remove
await next(context)from the middle one. Confirm the endpoint is never reached and the response is an empty 200. - Write
RequestTimingMiddlewareas a class with an extension method. Confirm it logs method, path, status and elapsed time. - Inject a scoped service into its constructor. Record the exception.
- Inject the same service into
InvokeAsyncinstead and confirm it works. - Add a mutable
int _countfield to the middleware and increment it. Hit the endpoint concurrently and observe. - Swap
UseAuthenticationandUseAuthorization. Call a[Authorize]endpoint with a valid token and record the result. - Move
UseExceptionHandlerto afterMapControllers. Throw from an action and compare the response. - Move
UseStaticFilesafterUseAuthentication, add[Authorize]globally, and confirm CSS returns 401. - Write short-circuiting middleware requiring an
X-Api-Keyheader. Confirm requests without it never reach the endpoint. - Write a middleware that sets a response header after
await next(context)on a request whose body has already been written. Record the exception, then guard withHasStarted. - Add a correlation-id middleware with
BeginScope. Confirm the id appears on every log entry for that request. - Use
UseWhento add a component only for/apipaths. Confirm it does not run for other paths and that the branch rejoins.
Exercises 2, 4 and 7 correspond to three real production incidents.
You can now
- Order a pipeline correctly and say what each ordering breaks
- Write custom middleware that calls
next() - Place the exception handler first
- Say why
UseCorssits between routing and authentication - Trace a request through the pipeline
Review questions
- Why must
UseAuthenticationcome beforeUseAuthorization? - What happens when a middleware does not call
await next(context)? - Why can a scoped service not be injected into a middleware constructor?
- When is a filter the right tool rather than middleware?