👋Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.
.NET Core Middleware and Pipeline: In-Depth with Code Examples
ASP.NET Core middleware: order, auth, CORS, security headers, and rate limiting.
December 23, 2024 · Waqas Ahmad
Read the article
Introduction
This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).
Putting global HTTP behaviour—error handling, CORS, auth, security headers, logging—inside controllers is hard to maintain and test. This article covers the ASP.NET Core middleware pipeline in depth: what middleware is, correct pipeline order, types (inline vs class-based), and step-by-step code for exception handling, HTTPS, CORS, authentication, authorization, claims, security headers, logging, rate limiting, and health checks. For architects and tech leads, keeping cross-cutting behaviour in one ordered pipeline keeps controllers clean and makes behaviour predictable and testable.
System scale: Varies by context; the approach in this article applies to the scales and scenarios described in the body.
Team size: Typically small to medium teams; ownership and clarity matter more than headcount.
Time / budget pressure: Applicable under delivery pressure; I’ve used it in both greenfield and incremental refactors.
Technical constraints: .NET and related stack where relevant; constraints are noted in the article where they affect the approach.
Non-goals: This article does not optimize for every possible scenario; boundaries are stated where they matter.
What is middleware and the pipeline?
Middleware is a component that handles HTTP requests and responses. It receives a RequestDelegate (the next middleware in the pipeline) and can: (1) call it to pass the request along, (2) modify the request or response and then call next, or (3) short-circuit (not call next) and return a response (e.g. 401, 429, or a health check). The pipeline is the ordered chain of middleware: each request passes through them in registration order. Order is critical—exception handling first, then HTTPS, CORS, auth, authorization, then endpoints.
RequestDelegate is a delegate: Task(HttpContext context). Your middleware receives it in the constructor and calls await _next(context) to pass the request to the next middleware.
Benefits: what middleware can do without touching your code
With middleware you can add cross-cutting behavior globally without changing controllers or endpoints:
What you get
Without middleware
With middleware
Error handling
Try/catch in every action
One exception middleware; all errors caught and formatted
HTTPS / HSTS
Manual redirect in each app
UseHttpsRedirection, UseHsts; all requests covered
CORS
CORS logic in controllers
UseCors; preflight and responses handled in one place
Authentication
Check token in every action
UseAuthentication; user set once for pipeline
Authorization
Check roles in every action
UseAuthorization + policies; 403 before endpoint
Claims enrichment
Load claims in each action
One middleware adds claims to context.User
Security headers
Set headers in every response
One middleware adds X-Frame-Options, CSP, etc.
Logging / correlation ID
Log in every action
One middleware logs request/response and adds correlation ID
Rate limiting
Check limits in every action
One middleware returns 429 before endpoint
Health checks
Dedicated controller
Terminal middleware for /health; no controller
Result: Controllers stay focused on business logic; middleware handles infrastructure and cross-cutting concerns.
Calls await _next(context) after (or before) its logic.
Logging, auth, CORS, most cross-cutting behavior
Use class-based middleware when you need DI (e.g. ILogger, IConfiguration) or reuse. Use inline for small, one-off logic. Use terminal only when the request should never reach endpoints (e.g. /health).
Pipeline order: recommended sequence
Order is critical. Recommended sequence in Program.cs:
Exception handling – Catch errors from all later middleware and format response.
HTTPS redirection / HSTS – Force HTTPS before any other logic.
Static files – Serve files without hitting app logic (optional).
Routing – Match URL to endpoint (required before UseCors with endpoint routing in some setups).
CORS – Set CORS headers; must run before auth so preflight (OPTIONS) succeeds.
Authentication – Identify the user (set context.User).
Authorization – Check if the user is allowed (return 403 if not).
Endpoints – Run the actual handler (MapControllers, MapGet, etc.).
Full example (Program.cs):
var app = builder.Build();
app.UseExceptionHandler("/Error"); // or UseExceptionHandler with lambda
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("MyPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Why this order: Exception handling first so any exception in later middleware is caught. CORS before auth so browser preflight (OPTIONS) gets CORS headers without auth. Auth before authorization so context.User is set. Authorization before endpoints so 403 is returned before your code runs.
Exception and error handling
UseExceptionHandler catches unhandled exceptions from later middleware and endpoints and returns a configurable error response without touching your controller code.
Step 1: Basic exception handler (redirect to error page)
app.UseExceptionHandler("/Error");
Step 2: Custom exception handler (return JSON or ProblemDetails)
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
await context.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
title = "An error occurred",
status = 500,
detail = ex?.Message
});
});
});
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
app.UseStatusCodePages(); // 404, 403, etc. as ProblemDetails
How this fits together: Any unhandled exception in the pipeline is caught by the exception handler; the request never reaches your endpoint with a crash. You return a consistent error shape (e.g. ProblemDetails) without try/catch in every action.
HTTPS redirection and security headers
UseHttpsRedirection redirects HTTP to HTTPS. UseHsts adds the Strict-Transport-Security header so browsers use HTTPS for future requests. Both apply globally without touching your code.
app.UseHttpsRedirection();
app.UseHsts(); // add in production; ensure HTTPS is working first
UseCors adds CORS headers to responses and handles preflight (OPTIONS) requests. Configure a policy in Program.cs; the middleware applies it before your endpoints run, so you never add CORS logic in controllers.
Step 2: Use CORS middleware (before UseAuthentication)
app.UseCors("MyPolicy");
How this fits together: Browser sends OPTIONS preflight; CORS middleware responds with allowed origin/methods/headers. Browser then sends the real request; CORS middleware adds Access-Control-Allow-Origin (and related) to the response. Your endpoints do nothing; CORS is handled in one place.
Authentication and authorization
UseAuthentication runs the registered authentication scheme(s) (e.g. JWT Bearer, cookies). It sets context.User from the token or cookie before the request reaches your endpoints. UseAuthorization runs policy checks (e.g. [Authorize], RequireRole) and returns 403 if the user is not allowed—without you writing checks in every action.
How this fits together: Authentication middleware runs first and sets context.User. Authorization middleware runs next; if the endpoint has [Authorize] or [Authorize(Policy = "AdminOnly")], the policy is evaluated. If it fails, 403 is returned and your action is not executed. No if (!User.IsInRole("Admin")) in controllers.
Claims addition and enrichment
You can add claims to context.User in a middleware that runs after UseAuthentication and before UseAuthorization (or endpoints). That way, downstream code (authorization, controllers) sees the enriched user without loading claims in every action.
Step 1: Middleware that enriches claims
publicclassClaimsEnrichmentMiddleware
{
privatereadonly RequestDelegate _next;
publicClaimsEnrichmentMiddleware(RequestDelegate next) => _next = next;
publicasync Task InvokeAsync(HttpContext context, IUserClaimsService claimsService)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
var extraClaims = await claimsService.GetClaimsForUserAsync(userId);
var identity = (ClaimsIdentity)context.User.Identity;
foreach (var c in extraClaims)
identity.AddClaim(c);
}
await _next(context);
}
}
Step 2: Register (after UseAuthentication, before UseAuthorization)
app.UseMiddleware<ClaimsEnrichmentMiddleware>();
Note: If IUserClaimsService is scoped (e.g. uses DbContext), inject IServiceScopeFactory in the middleware and create a scope inside InvokeAsync: using var scope = _scopeFactory.CreateScope(); var claimsService = scope.ServiceProvider.GetRequiredService<IUserClaimsService>(); Then use claimsService to load claims. Middleware is typically singleton, so you must not inject scoped services directly.
How this fits together: After authentication, context.User has basic claims (e.g. from JWT). Your middleware loads additional claims (e.g. from DB) and adds them to context.User. Authorization and controllers then see the full set of claims without each action loading them.
Response modification (headers, wrapping)
Middleware can add or modify response headers (e.g. X-Request-ID, X-Response-Time) without touching your code. It can also wrap the response body (e.g. buffer and modify) with more code; for simple header addition, run after _next and set headers.
How this fits together: Every response gets the headers without any code in controllers. Register after exception handling, before or after routing as needed.
Security headers (CSP, X-Frame-Options, etc.)
A single middleware can add security headers to every response so you don’t set them in every action:
How this fits together: All responses get the headers; no per-action code. Register early (e.g. after exception handling).
Request logging and correlation ID
Logging middleware logs each request (method, path) and optionally response status. Correlation ID middleware reads X-Correlation-ID from the request or generates a new GUID, sets it in context.Items and response headers, so logs can be traced per request.
Registration:app.UseMiddleware<RequestLoggingMiddleware>(); after exception handling, before routing.
Rate limiting and health checks
Rate limiting: Middleware can check a limit (e.g. per IP or per user) before calling _next. If exceeded, return 429 and do not call _next—your endpoints are never hit. In .NET 7+, use Microsoft.AspNetCore.RateLimiting (e.g. UseRateLimiter, AddFixedWindowLimiter).
Health checks:Terminal middleware for /health (or /ready) that returns 200 (and optionally checks DB, etc.) without hitting your controllers. Use MapGet("/health", () => Results.Ok()) or app.UseHealthChecks("/health") with AddHealthChecks.
How this fits together: Requests without a valid API key get 401 and never reach your endpoints. All API key logic is in one place.
Enterprise practices
Order – Exception handling first; CORS before auth; auth before authorization; authorization before endpoints.
Async – Use async/await in middleware; avoid Task.Wait() or blocking I/O.
Scoped services – Middleware is typically singleton. To use scoped services (e.g. DbContext), inject IServiceScopeFactory and create a scope inside InvokeAsync: using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
Error handling – Use UseExceptionHandler or ProblemDetails so all errors return a consistent shape.
Logging – Use a logging middleware (and correlation ID) so every request/response is logged without per-action code.
Security headers – One middleware for X-Frame-Options, CSP, etc.; apply globally.
Wrong order – Auth or CORS after endpoints; exception handling not first. Fix: use the recommended order.
Captive dependency – Singleton middleware injecting Scoped (e.g. DbContext). Fix: use IServiceScopeFactory and create a scope in InvokeAsync.
Blocking – Task.Wait() or sync I/O in middleware. Fix: use async/await.
Short-circuit and response already started – Setting status/headers or writing body after calling _next can fail. Fix: set headers before _next if you might short-circuit; for response body, either short-circuit without calling _next or use response buffering if you need to modify body after _next.
Summary
The ASP.NET Core middleware pipeline is an ordered chain of components that handle requests and responses without touching your endpoint code—error handling, HTTPS, CORS, auth, authorization, security headers, logging, rate limiting, health checks. Wrong order (e.g. auth after CORS or after endpoints) causes subtle bugs; correct order and class-based middleware with proper scoping keep behaviour predictable. Next, review your pipeline order (exception first, then HTTPS, CORS, auth, authorization, endpoints) and move any global logic out of controllers into middleware.
Position & Rationale
I treat the middleware pipeline as the single place for cross-cutting HTTP behaviour: exception handling, CORS, auth, security headers, logging, rate limiting. I avoid putting that logic in controllers or action filters—middleware runs in a defined order and keeps endpoints thin. I use class-based middleware with UseMiddleware<T> whenever I need DI or reuse; I reserve inline Use(lambda) for one-off checks. I reject running auth or CORS after endpoint routing in the same pipeline; order is non-negotiable for correctness. For claims enrichment I always use IServiceScopeFactory when the service is scoped—injecting a scoped dependency directly into middleware is a bug.
Trade-Offs & Failure Modes
What this sacrifices: You give up per-action control of cross-cutting behaviour; everything is global. Adding middleware adds latency to every request; heavy logic in middleware (e.g. DB calls for claims) can slow the pipeline.
Where it degrades: Under very high throughput, middleware that does I/O (e.g. claims from DB) can become a bottleneck. Wrong order (e.g. CORS after auth) causes subtle production failures that are hard to debug.
How it fails when misapplied: Captive dependency (singleton middleware holding scoped DbContext) causes concurrency and stale data. Setting status or writing response after calling _next can throw or be ignored. Blocking in middleware (Task.Wait()) can deadlock.
Early warning signs: “We added a check in the controller as well” means middleware is being bypassed. Repeated fixes to order (moving middleware up/down) or per-action overrides suggest the pipeline is not the single source of truth.
What Most Guides Miss
Docs rarely stress that middleware is effectively singleton for the app lifetime: you cannot inject scoped services (e.g. DbContext) in the constructor. The fix—IServiceScopeFactory and creating a scope inside InvokeAsync—is easy to miss, and the resulting bugs (shared instance across requests, disposed-too-early) are hard to trace. Another gap: short-circuiting must happen before you call _next; once the response has started, you cannot change status or headers. Tutorials show the happy path; production issues are usually order, scoping, or short-circuit discipline.
Decision Framework
If you need cross-cutting behaviour for every request → Put it in middleware in the correct order; exception handling first, then HTTPS, CORS, auth, authorization, endpoints.
If you need DI or reusable logic → Use class-based middleware and UseMiddleware<T>; for scoped dependencies use IServiceScopeFactory inside InvokeAsync.
If you need to short-circuit (401, 429, health) → Do not call _next; set status and write response, then return.
If CORS or auth “sometimes works” → Fix order: CORS before UseAuthentication so preflight succeeds; UseAuthentication before UseAuthorization so context.User is set.
If you are adding “one more check” in a controller → Prefer moving it into middleware so the pipeline stays the single place for that concern.
You can also explore more patterns in the .NET Architecture resource page.
Key Takeaways
Middleware is the right place for global HTTP behaviour; keep controllers free of auth, CORS, and logging logic.
Order is critical: exception handling → HTTPS → routing → CORS → authentication → authorization → endpoints.
Use class-based middleware for anything that needs DI or reuse; use IServiceScopeFactory for scoped services, never inject them in the constructor.
Short-circuit before calling _next; avoid blocking and captive dependencies.
Revisit pipeline order and scoping when adding new middleware or debugging auth/CORS issues.
Need architectural guidance for real-world .NET platforms? I offer consulting for .NET architecture, API platforms, and enterprise system design.
When I Would Use This Again — and When I Wouldn’t
I would use the pipeline approach again for any ASP.NET Core API or web app that needs global error handling, CORS, auth, or cross-cutting headers—greenfield or refactor. I wouldn’t put business rules or per-endpoint logic in middleware; that belongs in filters or application code. I’d skip a heavy custom middleware stack when the app is a tiny internal tool with no auth and no cross-origin calls; the built-in minimum (exception handler, routing, endpoints) is enough. If the team keeps adding checks in controllers instead of the pipeline, I’d treat that as a signal to consolidate and document order once, then enforce it in review.
Frequently Asked Questions
Frequently Asked Questions
What is middleware?
Middleware is a component that handles HTTP requests and responses. It receives the next delegate in the pipeline and can call it to pass the request along or short-circuit (e.g. return 401). Examples: authentication, logging, CORS, error handling.
What is the middleware pipeline?
The pipeline is the ordered chain of middleware components. Each request passes through them in registration order. Order matters: exception handling first, then HTTPS, CORS, auth, authorization, endpoints.
What is RequestDelegate?
RequestDelegate is a delegate that represents the next middleware in the pipeline. It takes HttpContext and returns Task. Your middleware receives it in the constructor and calls await _next(context) to pass the request to the next middleware.
Why does middleware order matter?
Order determines what middleware sees the request first. Exception handling must be first to catch errors. CORS must run before auth so preflight succeeds. Auth must run before authorization so context.User is set. Authorization must run before endpoints so 403 is returned before your code runs.
How do I create custom middleware?
Create a class with a constructor that takes RequestDelegate (and optionally ILogger or other services). Add a method InvokeAsync(HttpContext context). Register with app.UseMiddleware<YourMiddleware>();.
What is short-circuiting?
Not calling await _next(context)—the request does not reach later middleware or endpoints. Use for auth failures (401), rate limiting (429), health checks, or API key rejection.
Can middleware use DI?
Yes. Constructor injection works for singleton services. For scoped services (e.g. DbContext), inject IServiceScopeFactory and create a scope inside InvokeAsync, then resolve the scoped service from the scope.
What are common middleware order mistakes?
Auth or authorization after endpoints (unauthenticated requests hit controllers). CORS after auth (preflight may fail). Exception handling not first (errors not caught). Fix: use the recommended order.
UseMiddleware vs Use vs Run?
UseMiddleware<T> for class-based middleware with DI. Use for inline middleware (lambda). Run for terminal middleware (never calls next).
How do I add correlation ID?
Middleware reads X-Correlation-ID from the request or generates a new GUID. Store it in context.Items["CorrelationId"] and set context.Response.Headers["X-Correlation-ID"]. Use it in logging so all logs for the request share the same id.
What is captive dependency in middleware?
Singleton middleware holding a reference to a scoped service (e.g. DbContext). The scoped instance is never disposed correctly and can cause concurrency or stale data. Fix: inject IServiceScopeFactory and create a scope in InvokeAsync when you need a scoped service.
Where do I put custom middleware?
After exception handling (so your middleware’s errors are caught). Before routing if it does not need the matched endpoint; before auth/authorization if it needs to run for unauthenticated requests (e.g. logging, CORS). Put claims enrichment after UseAuthentication, before UseAuthorization.
How do I test middleware?
Unit test: create a DefaultHttpContext, mock or set up the next delegate, invoke your middleware, assert on context.Response and whether next was called. Integration test: use WebApplicationFactory and send requests; assert on headers and status codes.
How do I add security headers (X-Frame-Options, CSP)?
Create a middleware that sets context.Response.Headers["X-Frame-Options"], X-Content-Type-Options, Referrer-Policy, and optionally Content-Security-Policy before calling _next(context). Register early in the pipeline (e.g. after exception handling).
How do I enrich claims in middleware?
Run a middleware after UseAuthentication that reads context.User, loads additional claims (e.g. from DB via a scoped service), and adds them to (ClaimsIdentity)context.User.Identity. Then call _next(context). Use IServiceScopeFactory to resolve scoped services.
Related Guides & Resources
Explore the matching guide, related services, and more articles.