DEV Community

Cover image for Middleware Order Is Behavior, Not Style
Kazem
Kazem

Posted on

Middleware Order Is Behavior, Not Style

Middleware ordering looks like a formatting preference, like where you put your using statements. It isn't. Put UseAuthorization() before UseAuthentication() in a pipeline and every request sails through as anonymous. No error, no warning, nothing in the logs to flag it. The app just quietly stops checking who anyone is.

That's the thing about ASP.NET Core middleware: it's a pipeline, and pipelines are order-sensitive by nature. Each component gets the HttpContext, can act before and after the next one runs, and can short-circuit the whole thing. Swap two lines and you haven't changed how the code looks: you've changed what "authenticated" or "handled" means for every request that comes through. And the framework won't tell you.

The short-circuit and what runs after it

Here's a tenant-resolution middleware that shows most of the moving parts at once:

public sealed class TenantMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context, ITenantStore store)   // scoped svc injected per-call
    {
        var tenantId = context.Request.Headers["X-Tenant"].FirstOrDefault();

        if (string.IsNullOrEmpty(tenantId))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Title = "Missing X-Tenant header.", Status = 400
            });
            return;                                     // short-circuit: next is never called
        }

        context.Items["Tenant"] = await store.FindAsync(tenantId);

        await next(context);                            // everything after this runs on the way out

        context.Response.Headers["X-Tenant"] = tenantId; // ⚠ too late if the response already started
    }
}

app.UseMiddleware<TenantMiddleware>();
Enter fullscreen mode Exit fullscreen mode

If the header is missing, we return a 400 and never call next(context). That's the short-circuit: nothing further down the pipeline runs, including routing, auth, or the endpoint itself. It's a deliberate gate, and it's the same mechanism that makes ordering matter everywhere else in the pipeline: whatever runs before next() sees the request on the way in, and whatever runs after sees it on the way out, only if something further down didn't already short-circuit it.

That last line is worth pausing on: setting a response header after await next(context) assumes the response hasn't started yet. If anything downstream has already begun streaming the response, which is common the moment you're dealing with larger payloads or certain content types, that header write does nothing. No exception, no warning. It just silently fails to take effect.

There's a second trap in that same snippet that has nothing to do with ordering: ITenantStore store is injected as a parameter on InvokeAsync, not through the constructor. That's not a style choice either. Convention-based middleware like this is instantiated once, at startup, and reused as a singleton for the lifetime of the app. If you constructor-inject a scoped service, like most DbContext-backed stores, you're capturing it once and reusing that same instance across every request, which is exactly the kind of bug that only shows up under concurrent load, well after you've stopped looking at this file. Injecting scoped services as InvokeAsync parameters sidesteps that, because the DI container resolves them fresh per request. The other option is to implement IMiddleware and register it explicitly, which gives you per-request instantiation instead of the singleton convention.

The pipeline as a dependency graph

Once you've internalized that middleware runs in a strict, request-in/response-out sequence, the rest of a typical pipeline reads less like a checklist and more like a dependency graph:

app.UseExceptionHandler();      // outermost — must wrap everything to catch it
app.UseHsts();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();               // decides WHICH endpoint
app.UseRateLimiter();
app.UseCors();
app.UseAuthentication();        // WHO are you        — must be after UseRouting
app.UseAuthorization();         // are you ALLOWED    — must be after authentication
app.UseOutputCache();
app.MapControllers();           // executes the endpoint
Enter fullscreen mode Exit fullscreen mode

UseExceptionHandler() goes first because it needs to wrap everything else. Register it last and it's inside the pipeline instead of around it, so it won't catch exceptions thrown by the middleware registered before it. That's easy to miss because a handler like this can look correct in isolation and pass a test built around a deliberately-thrown exception in a controller action, while still doing nothing the one time an earlier middleware fails.

UseAuthentication() has to come after UseRouting(), and UseAuthorization() has to come after UseAuthentication(). Flip that last pair, authorization before authentication, and you get the anonymous-request problem: every request is evaluated as anonymous because the authorization middleware runs before anything has had a chance to establish who the caller is. The request doesn't error. It just gets treated as if no one is logged in, which in a lot of setups means either "reject everyone" or, worse, "allow everyone" depending on how your policies are written.

Neither mistake throws a compile-time error or a runtime warning. The pipeline builds fine. Requests come through. The behavior is just wrong, and the only way to catch it is to actually exercise the auth path or read the pipeline order carefully enough to reason through what each stage assumes about the ones before it.

Handle exceptions once, centrally

The UseExceptionHandler() line pairs with an IExceptionHandler implementation, which is where cross-cutting exception handling actually lives:

public sealed class DomainExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not DomainException domain) return false;   // let the next handler try

        ctx.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;

        return await problems.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = ctx,
            ProblemDetails = { Title = "Domain rule violated", Detail = domain.Message }
        });
    }
}

builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddProblemDetails();
Enter fullscreen mode Exit fullscreen mode

If you find yourself wrapping every controller action in try/catch, that's usually a sign the exception handling belongs here instead. Registering multiple IExceptionHandler implementations lets you handle different exception types separately (TryHandleAsync returning false just passes the exception to the next registered handler), while every response still comes back as a consistent RFC 9457 ProblemDetails payload instead of whatever ad hoc shape each action happened to return.

Middleware or endpoint filter?

Not everything belongs in the middleware pipeline just because it can go there. The distinction that's actually useful:

Use middleware for Use an endpoint filter / action filter for
Anything cross-cutting on every request (logging, auth, correlation IDs, compression) Concerns tied to a specific endpoint or group (validation, idempotency keys, resource authorization)
Work that must run before routing Work that needs the bound arguments / model

Middleware runs before the framework has matched a route or bound any parameters, so it's the right place for things that apply uniformly: logging every request, stamping a correlation ID, checking who the caller is. The moment you need the actual bound arguments of a specific endpoint (validating a request body, checking an idempotency key against a specific resource), you're past what middleware can see cleanly, and an endpoint filter is the better fit. Trying to do argument-level validation in middleware usually means re-parsing or re-binding something the framework is about to do anyway.

None of this is complicated once you see the pipeline for what it is: a strict, ordered sequence where each stage's correctness depends on assumptions about what already ran. The bugs aren't exotic. They're one-line reorderings that compile clean, run clean, and quietly change what your API does.

Top comments (0)