The middleware pipeline: order matters
I've spent countless hours debugging authentication failures, mysterious 401 responses, and CORS preflight requests that silently vanish into the void. Almost every time, the culprit wasn't broken code—it was middleware in the wrong order. In ASP.NET Core, middleware is a chain of responsibility where each piece wraps the next, and the sequence determines whether your request succeeds or fails. Get the order wrong, and you'll spend hours wondering why your JWT is valid but still rejected, or why CORS works in development but not production.
Understanding the middleware pipeline as a stack
Before diving into order, let's clarify how middleware actually works. When you call app.UseX(), you're adding a layer that wraps all the middleware registered after it. Think of it like nested Russian dolls: the first middleware you register is the outermost doll, and the last middleware (your endpoint handlers) is the innermost.
A request flows through each layer on the way in, and then back through each layer on the way out. This means early middleware sees the raw request first, and late middleware sees a potentially modified request. For security decisions, you want to validate before you trust. For infrastructure concerns like CORS, you need routing information available first.
The recommended middleware order
I've found this order works reliably across most production systems:
- Exception handling — Outermost layer, catches everything below
- HSTS (HTTP Strict Transport Security)
- HTTPS redirection
- Static file serving
- Routing — Must come before CORS
- CORS — Must come after routing
- Authentication — Identifies the user
- Authorization — Checks if that user can act
- Rate limiting — Optional, but useful before endpoints
- Endpoint routing/handlers — Innermost layer
var builder = WebApplicationBuilder.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("RequireAdmin", p => p.RequireRole("Admin"));
builder.Services.AddCors(opts => opts.AddPolicy("AllowFrontend",
p => p.WithOrigins("https://app.example.com").AllowAnyMethod()));
var app = builder.Build();
// 1. Exception handling
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
}
// 2. HSTS
app.UseHsts();
// 3. HTTPS redirection
app.UseHttpsRedirection();
// 4. Static files
app.UseStaticFiles();
// 5. Routing (MUST come before CORS)
app.UseRouting();
// 6. CORS (MUST come after routing)
app.UseCors("AllowFrontend");
// 7. Authentication
app.UseAuthentication();
// 8. Authorization
app.UseAuthorization();
// 9. Custom middleware (rate limiting, correlation IDs, etc.)
app.UseMiddleware<RequestTimingMiddleware>();
app.MapGet("/public", () => "No auth needed");
app.MapGet("/protected", RequireAuth, () => "Auth needed")
.WithName("Protected")
.WithOpenApi();
app.Run();
Authentication before Authorization: the critical dependency
This is the mistake I see most often. Authorization middleware needs to know who the user is. If you reverse them—calling UseAuthorization() before UseAuthentication()—every protected endpoint returns 401 Unauthorized, even with valid credentials.
Here's why: UseAuthentication() reads the JWT (or session, or API key) and populates User on the HttpContext. UseAuthorization() then checks if that User satisfies the endpoint's policy. No User means no authorization can succeed.
// WRONG - Authorization before Authentication
app.UseAuthorization();
app.UseAuthentication();
// Request with valid JWT arrives
// UseAuthorization runs: User is null, no policy can pass, returns 403
// UseAuthentication never runs to extract the token
// CORRECT - Authentication before Authorization
app.UseAuthentication();
app.UseAuthorization();
// Request with valid JWT arrives
// UseAuthentication runs: Extracts JWT, validates, sets User
// UseAuthorization runs: Checks User against endpoint policies
I learned this the hard way in a CRM rebuild where we migrated auth schemes. The legacy code had them reversed, and it only worked because everyone had explicit role assignments in the session. When we switched to JWT, nothing protected. The JWT was valid, but it never got checked because auth ran after auth checks.
CORS after routing: the preflight puzzle
CORS is a browser security feature. Before sending a request with credentials or custom headers, the browser sends an OPTIONS preflight request. The server must respond with the right Access-Control-* headers.
The gotcha: CORS middleware needs to know which endpoint you're calling to apply the right policy. That requires routing information. If you call UseCors() before UseRouting(), the middleware has no route context, and preflight requests fail mysteriously—they return 200, but without the required headers.
// WRONG - CORS before Routing
app.UseRouting();
app.UseCors("AllowFrontend");
// Preflight OPTIONS request arrives
// No route information yet, CORS can't determine which endpoint
// Browser sees missing Access-Control-Allow-Origin header
// Request fails before it even tries the actual endpoint
// CORRECT - Routing before CORS
app.UseRouting();
app.UseCors("AllowFrontend");
// Preflight OPTIONS request arrives
// Routing identifies the endpoint
// CORS middleware sees the route, applies the matching policy
// Returns proper headers, browser allows the actual request
On an e-commerce platform I debugged, the API was returning 200 for CORS preflight but the browser rejected it anyway. We'd moved UseCors() during a refactor without thinking about routing. The fix was one line: reorder them. Suddenly preflight worked and checkout stopped timing out.
Write your own: building custom middleware
Middleware is just a delegate. This makes it surprisingly easy to add custom behavior: correlation IDs, request timing, logging, custom authentication schemes, or request/response inspection.
Here's a practical middleware I use on almost every system—request timing for performance monitoring:
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 sw = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
sw.Stop();
_logger.LogInformation(
"Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
// Register in pipeline
app.UseMiddleware<RequestTimingMiddleware>();
Or using inline middleware for simpler cases:
app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next(context);
sw.Stop();
logger.LogInformation("{Path} {Ms}ms",
context.Request.Path, sw.ElapsedMilliseconds);
});
This catches slowdowns early. On a ride-hailing backend, we discovered that one payment gateway was timing out after 2 seconds—but only under load. The request timing middleware showed us exactly which calls were slow, and we added circuit breaker logic to fail fast instead of blocking.
Common mistakes
- Auth before routing: You can put authentication before routing, but it means you can't have public endpoints that skip certain middleware. Better to route first so you can apply policies per-endpoint.
- Static files after authentication: If you protect static files with auth, every image, CSS, or JS file needs authentication. Usually you want
UseStaticFiles()early and unauthenticated. Useapp.UseStaticFiles(new StaticFileOptions { RequestPath = "/protected-assets" })for protected static content. - Rate limiting in the wrong place: Rate limiting works best after authentication (so you can rate-limit per user) but before expensive operations. If you put it too early, you rate-limit all requests including static files.
- Custom middleware order ambiguity: When adding your own middleware, think about what it needs. Correlation ID middleware should be very early. Business logic middleware should be late. Logging middleware should wrap everything.
- Forgetting about OPTIONS requests: CORS preflight is OPTIONS. Make sure your endpoint handler allows OPTIONS, or middleware returns it.
MapGet(...).WithOpenApi()handles this automatically, but bare routing might not.
Key takeaways
- Middleware is a stack: early middleware wraps late middleware. The order determines what information is available and whether requests succeed.
- Always authenticate before authorizing. Authorization needs
Userpopulated by authentication. - Route before CORS. CORS needs routing information to apply the right policy to preflight requests.
- Static files, HTTPS, and exception handling belong near the top. Endpoints and authorization belong near the bottom.
- Custom middleware is just a delegate. Use it for timing, correlation IDs, and cross-cutting concerns, and place it thoughtfully in the pipeline.
- When debugging auth or CORS failures, the first thing I check is middleware order. It's often the answer.






Comments
Loading comments…