Middleware order is the single most common source of "it works locally, it 401s in production" in ASP.NET Core. The pipeline is not a list of features you switch on — it is an ordered chain, and each link can stop the request before the next one ever runs.
The mental model: a chain, not a checklist
Every app.Use... call adds one link. A request walks down the chain, and the response walks back up it. That second half is the part most people forget, and it is why a middleware can inspect a status code that a later middleware produced.
flowchart TD
A[Request arrives] --> B[Exception handler]
B --> C[HSTS / HTTPS redirect]
C --> D[Static files]
D --> E[Routing]
E --> F[CORS]
F --> G[Authentication]
G --> H[Authorization]
H --> I[Endpoint / Controller]
I --> J[Response walks back up]
J --> A
Two consequences fall straight out of this picture:
- Anything that short-circuits stops everything below it.
UseStaticFilesresponds and returns without calling the next link, which is exactly why a static file never hits your authorization rules. - Anything that wraps the rest must be registered first. The exception handler can only catch what runs after it.
Order that actually matters
This is the order the framework expects. It is not stylistic.
var app = builder.Build();
app.UseExceptionHandler("/error"); // first: it must wrap everything below
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles(); // before routing: cheap exit for assets
app.UseRouting(); // decides WHICH endpoint - must precede auth
app.UseCors("default"); // after routing, before auth
app.UseAuthentication(); // who are you?
app.UseAuthorization(); // are you allowed? needs the endpoint + the identity
app.MapControllers(); // the endpoint finally executes
app.Run();
Why UseRouting must come before UseAuthorization: authorization needs to know which endpoint was selected in order to read that endpoint's [Authorize] metadata. Put authorization first and it has no endpoint to inspect, so the attribute is silently not enforced. That is a security bug that no test catches unless you write one for it.
Short-circuiting, concretely
A middleware that does not call next ends the request there and then.
sequenceDiagram
participant C as Client
participant R as RateLimiter
participant A as Authentication
participant E as Endpoint
C->>R: GET /api/orders
R-->>C: 429 Too Many Requests
Note over A,E: never reached - the chain stopped at the limiter
In code, the difference is one await:
app.Use(async (context, next) =>
{
if (IsOverLimit(context))
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
return; // short-circuit: nothing below runs
}
await next(context); // continue down the chain
// This line runs on the way back UP, after the endpoint produced a response.
if (context.Response.StatusCode == StatusCodes.Status401Unauthorized)
{
Log.Warning("Unauthorized {Path}", context.Request.Path);
}
});
Three ordering bugs worth recognising
1. UseCors after UseAuthorization. The browser's preflight OPTIONS request carries no credentials, so authorization rejects it before CORS ever adds its headers. The browser then reports a CORS error, which sends you hunting in the wrong place entirely. The actual fault is ordering.
2. Writing to the response after next. Once the response has started, headers are already on the wire:
await next(context); context.Response.Headers["X-Trace"] = traceId; // throws: response already started
Use context.Response.OnStarting(...) to register a callback that runs just before the first byte is flushed.
3. Exception handler registered late. Anything registered above it throws into the host, and the client gets a bare connection reset instead of your error contract.
How to verify it rather than trust it
Order is observable. Log on the way down and on the way back up, and the nesting becomes obvious:
app.Use(async (context, next) =>
{
Console.WriteLine($"-> {context.Request.Path}");
await next(context);
Console.WriteLine($"<- {context.Response.StatusCode}");
});
For authorization specifically, assert the behaviour instead of reading the startup file:
[Fact]
public async Task Protected_endpoint_rejects_anonymous_caller()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/api/orders");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
If UseRouting and UseAuthorization are in the wrong order, that test fails — which is the point. A comment in Program.cs cannot fail.
Questions you should be able to answer
- Why must
UseRoutingprecedeUseAuthorization? - What happens to a request for
/logo.pngwhenUseStaticFilessits aboveUseAuthentication? - Where would you add a header that must appear on every response, including error responses?
- A middleware logs a 401 that your controller never returned. Where did it come from?
- Why does moving
UseCorsbelowUseAuthorizationbreak preflight but not simple GETs?
The short version: the pipeline is ordered, it runs in both directions, and any link can end the request. Most "mysterious" auth and CORS failures are that ordering, not the feature.