Skip to main content
Published / updated

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, Run and Map
  • 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:

RuleWhat breaks otherwise
UseForwardedHeaders firstWrong scheme and host behind a proxy; redirect loops
Exception handling earlyExceptions thrown before it are unhandled — a raw 500 with a stack trace
Static files before routingEvery image goes through routing and auth unnecessarily
UseRouting before CORS and authThe endpoint is not selected yet, so endpoint-specific policies do not apply
UseCors after routing, before authPreflight OPTIONS requests fail with 401
Authentication before authorizationThe user is always anonymous, so every [Authorize] returns 401
Endpoints lastNothing 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.

MiddlewareFilters
LevelEvery requestMVC actions only
Knows the actionNoYes
Access to model bindingNoYes
Access to the action resultNoYes
ScopeGlobal, or a branchGlobal, 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

SymptomLikely cause
A valid token is rejected with 401UseAuthorization before UseAuthentication
Preflight OPTIONS returns 401 or 404UseCors missing, or after UseAuthentication
Exceptions produce a raw stack traceUseExceptionHandler registered too late
Static files return 401UseStaticFiles after UseAuthentication
Wrong scheme in generated URLsUseForwardedHeaders missing or not first
An endpoint is never reachedSomething short-circuited — a middleware not calling next
"Headers are read-only"Modifying the response after it started
context.User has no claimsReading it before UseAuthentication
A custom middleware runs once and never againIt 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 seeCauseFix
Every request returns 401, even with a valid tokenUseAuthorization before UseAuthenticationSwap them
CORS fails only on authenticated callsUseCors after UseAuthenticationMove it before
CORS does nothing at allUseCors before UseRoutingPut it after
Requests hang or return an empty 200Custom middleware never calls next()Call it
The exception handler never firesRegistered too lateIt must be first
Static files 404UseStaticFiles missing or after routingAdd 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

  • UseAuthorization before UseAuthentication
  • UseCors before UseRouting, or after UseAuthentication
  • 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
  • Map where UseWhen was needed, so the branch never rejoins
  • Middleware where a filter was the right tool
  • Registering anything after MapControllers and expecting it to run

Practice

  1. Write three inline middleware components that log on entry and exit. Run one request and record the output order.
  2. Remove await next(context) from the middle one. Confirm the endpoint is never reached and the response is an empty 200.
  3. Write RequestTimingMiddleware as a class with an extension method. Confirm it logs method, path, status and elapsed time.
  4. Inject a scoped service into its constructor. Record the exception.
  5. Inject the same service into InvokeAsync instead and confirm it works.
  6. Add a mutable int _count field to the middleware and increment it. Hit the endpoint concurrently and observe.
  7. Swap UseAuthentication and UseAuthorization. Call a [Authorize] endpoint with a valid token and record the result.
  8. Move UseExceptionHandler to after MapControllers. Throw from an action and compare the response.
  9. Move UseStaticFiles after UseAuthentication, add [Authorize] globally, and confirm CSS returns 401.
  10. Write short-circuiting middleware requiring an X-Api-Key header. Confirm requests without it never reach the endpoint.
  11. 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 with HasStarted.
  12. Add a correlation-id middleware with BeginScope. Confirm the id appears on every log entry for that request.
  13. Use UseWhen to add a component only for /api paths. 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 UseCors sits between routing and authentication
  • Trace a request through the pipeline

Review questions

  1. Why must UseAuthentication come before UseAuthorization?
  2. What happens when a middleware does not call await next(context)?
  3. Why can a scoped service not be injected into a middleware constructor?
  4. When is a filter the right tool rather than middleware?

Next: Configuration and environments