Skip to content
Home C# / .NET Reversed Auth Middleware Order — Silent 401 in ASP.NET Core

Reversed Auth Middleware Order — Silent 401 in ASP.NET Core

Where developers are forged. · Structured learning · Free forever.
📍 Part of: ASP.NET → Topic 3 of 14
A valid JWT yields 401 when UseAuthorization runs before UseAuthentication.
⚙️ Intermediate — basic C# / .NET knowledge assumed
In this tutorial, you'll learn
A valid JWT yields 401 when UseAuthorization runs before UseAuthentication.
  • The pipeline is a two-phase stack, not a one-way conveyor — every middleware that calls next() gets to act on both the incoming request (before next) and the outgoing response (after next). This is how UseExceptionHandler and timing middleware work.
  • Order is everything and it's silent — UseAuthorization before UseAuthentication compiles and starts fine but returns 401 for every authenticated request. The recommended order exists because each layer depends on the layer above it having already run.
  • Scoped services belong on InvokeAsync parameters, not constructors — middleware instances live for the lifetime of the app (singleton-ish), so injecting a scoped DbContext into the constructor causes a scope capture bug. The InvokeAsync parameter injection is the correct, framework-supported pattern.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • ASP.NET Core middleware is a chain of delegates that process HTTP requests and responses in a two-phase pipeline.
  • Each middleware can inspect, modify, or short-circuit the request/response.
  • Order is critical: each middleware depends on the layer above having run.
  • Performance overhead is minimal (sub-millisecond per middleware) but incorrect order silently breaks security.
  • Production insight: Putting UseAuthorization before UseAuthentication returns 401 on every authenticated request.
  • Biggest mistake: Injecting a scoped DbContext into the middleware constructor instead of InvokeAsync parameters.
🚨 START HERE

Middleware Quick Debug Cheat Sheet

Common middleware failures and the exact commands & fixes to resolve them in minutes
🟡

All authenticated requests return 401

Immediate ActionCheck middleware order: UseAuthentication must precede UseAuthorization.
Commands
curl -v -H 'Authorization: Bearer <token>' http://localhost:5000/api/secure | grep HTTP
Check Program.cs for middleware registration sequence. Use dotnet list configuration to verify appsettings.
Fix NowReorder middleware to follow Microsoft's recommended pipeline: exception → HSTS → HTTPS → static → routing → CORS → auth → endpoints.
🟡

Middleware throws 'Cannot consume scoped service from singleton'

Immediate ActionMove scoped service injection from constructor to InvokeAsync parameters.
Commands
In your middleware class, change 'public MyMiddleware(RequestDelegate next, MyDbContext db)' to 'public async Task InvokeAsync(HttpContext context, MyDbContext db)'
Remove the scoped parameter from constructor and keep only singleton-safe services (ILogger, IOptions).
Fix NowReplace constructor injection with InvokeAsync parameter injection and rebuild.
🟡

Middleware registered after app.Run() never runs

Immediate ActionMove all middleware before the terminal app.Run() call.
Commands
grep -n 'app\.Run\|app\.Use\|app\.Map' Program.cs to see registration order
Inspect the last registration: if it's app.Run(), everything after is dead code.
Fix NowRestructure: use app.Use() for all non-terminal middleware, and place app.Run() only at the end with a fallback handler.
Production Incident

The Silent 401: When Middleware Order Strikes at 3 AM

A production deployment caused all authenticated endpoints to return 401, even with valid JWTs. No errors, no exceptions — just silent failures.
SymptomAfter deploying a new version, every API call with a valid JWT returned 401 Unauthorized. The authentication logs showed the token was valid, and the debugger confirmed the identity was correctly set in UseAuthentication.
AssumptionThe team assumed the JWT configuration was correct (issuer, secret, audience) and that the [Authorize] attribute was being enforced properly. They spent hours checking token validation parameters.
Root causeThe middleware order in Program.cs had been inadvertently changed during a refactor: UseAuthorization() was placed before UseAuthentication(). The authorization middleware checked context.User, which was still an anonymous identity because authentication hadn't run yet. No exception, no warning — every request that hit an [Authorize] endpoint returned 401.
FixReordered the middleware to place UseAuthentication() immediately before UseAuthorization(). The recommended pipeline: exception handler → routing → cors → authentication → authorization → endpoints. Deployed the fix and all endpoints returned 200.
Key Lesson
The order of middleware is causal, not decorative: authentication builds the user identity; authorization reads it. Reversing them is a silent failure.Always run a simple integration test after any middleware registration change — send a request with a valid token and assert the response is not 401.Use code analysis or a custom middleware that logs the pipeline order at startup to catch accidental reordering.
Production Debug Guide

Symptom → Action: Diagnose common middleware problems fast

Middleware registered in Program.cs never executesCheck if an app.Run() call appears before it. app.Run() is terminal — everything after it is silently ignored. Ensure non-terminal middleware uses app.Use() not app.Run().
All requests return 401 even with valid tokenVerify middleware order: UseAuthentication() must be before UseAuthorization(). Also confirm that CORS middleware is after UseRouting and before UseAuthentication.
InvalidOperationException at startup: 'Cannot consume scoped service from singleton'You injected a scoped service (e.g., DbContext) into the middleware constructor. Move it to the InvokeAsync method parameter. The framework resolves scoped services per request via DI.
CORS preflight requests (OPTIONS) fail with 405Ensure UseCors() is placed after UseRouting() and before UseAuthentication(). Preflight requests should not require authentication. Also verify the CORS policy allows the requested origin and method.
Response headers set in middleware not appearing in clientCheck if a middleware after yours is overriding or clearing headers. Also ensure headers are set before the response starts streaming (before calling WriteAsync). Use OnStarting callback to set headers just before flush.

Every web framework needs a way to handle the messy, repetitive work that surrounds every request — logging it, authenticating the caller, compressing the response, catching exceptions. Without a clean mechanism for this, you'd be copy-pasting the same authentication check into every single controller action. That's not just tedious; it's a reliability disaster. ASP.NET Core solves this with a first-class middleware pipeline that's fast, composable, and completely in your control.

The problem middleware solves is cross-cutting concerns. Authentication doesn't belong to any one feature — it belongs to all of them. The same goes for logging, CORS headers, rate limiting, and exception handling. Middleware lets you write that logic once, plug it into the pipeline in the right place, and trust that it runs for every request that flows through your app. The pipeline model also means concerns are layered cleanly: outer middleware wraps inner middleware, so you can handle exceptions around everything, authenticate before authorisation, and authorise before routing — all without tangling those concerns together.

By the end of this article you'll be able to draw the ASP.NET Core request pipeline from memory, explain why order matters (with a concrete example of what breaks when you get it wrong), write a custom middleware class with proper dependency injection, and use the three ways to register middleware — UseMiddleware, Use, and Map — choosing the right tool for each situation.

How the ASP.NET Core Request Pipeline Actually Works

The pipeline is a linked chain of delegates. When a request arrives, ASP.NET Core calls the first delegate. That delegate can do work, then call 'next()' to invoke the next delegate in the chain, then do more work after next() returns. This means every piece of middleware has two opportunities to act: once on the way in (before calling next) and once on the way out (after next returns). That's the key mental model — it's not a one-way conveyor belt, it's a stack of nested function calls.

The chain terminates at a 'terminal middleware' — something like UseEndpoints — that handles the request and writes a response without calling next. If no terminal middleware matches, ASP.NET Core returns a 404.

Each middleware is registered in Program.cs using extension methods on WebApplication. The order you call those methods is the order they execute. This isn't a detail — it's the most important rule in ASP.NET Core middleware. Authentication must come before Authorisation. Exception handling must wrap everything else. We'll see exactly what breaks when that order is wrong in the Gotchas section.

PipelineVisualization.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334
// Program.cs — stripped-down app to visualise the two-phase pipeline
// Run this and watch the console output to see requests flow IN and OUT

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Middleware A — outermost layer (first in, last out)
app.Use(async (context, next) =>
{
    Console.WriteLine("[A] Incoming — path: " + context.Request.Path);
    
    await next(context);   // hand control to the next middleware
    
    // This line runs AFTER the inner middlewares have all finished
    Console.WriteLine("[A] Outgoing — status: " + context.Response.StatusCode);
});

// Middleware B — middle layer
app.Use(async (context, next) =>
{
    Console.WriteLine("[B] Incoming");
    await next(context);
    Console.WriteLine("[B] Outgoing");
});

// Terminal middleware — writes the response, does NOT call next
app.Run(async context =>
{
    Console.WriteLine("[Terminal] Writing response");
    context.Response.StatusCode = 200;
    await context.Response.WriteAsync("Hello from the pipeline!");
});

app.Run();
▶ Output
[A] Incoming — path: /
[B] Incoming
[Terminal] Writing response
[B] Outgoing
[A] Outgoing — status: 200
🔥The Stack Mental Model
Read your middleware registrations like a stack: the first one registered is the outermost wrapper. Whatever you put first in Program.cs gets to inspect both the raw incoming request AND the final outgoing response. That's why UseExceptionHandler goes first — it needs to catch exceptions thrown by everything else beneath it.
📊 Production Insight
The two-phase pipeline is not just a mental model—it's how UseExceptionHandler catches exceptions.
If an exception is thrown in the request phase, the exception handler on the outer layer still catches it during the outgoing phase.
Rule: Always place exception handling middleware as the first registration to ensure it wraps all downstream errors.
🎯 Key Takeaway
The pipeline is a stack: first registered = outermost wrapper.
Code after await next() runs after all inner middleware have completed.
This two-phase design enables patterns like timing, logging, and exception handling.

Writing a Real Custom Middleware Class (With Dependency Injection)

Inline lambdas with app.Use() are fine for trivial cases, but anything non-trivial should be a dedicated class. A middleware class gives you a testable unit, proper constructor injection, and a home for the logic that doesn't clutter Program.cs.

The convention ASP.NET Core uses for middleware classes is simple: your class needs a constructor that accepts a RequestDelegate (the next middleware), and an InvokeAsync method that accepts HttpContext. That's the entire contract. You don't implement any interface — the framework discovers the method by convention.

Scoped services can't be injected via the constructor because middleware is instantiated once (singleton lifetime). Instead, inject scoped services as parameters of InvokeAsync — the framework handles that automatically. This is a common interview question and a common production bug. The example below builds a request-timing middleware that logs how long each request takes, which is something almost every production app needs.

RequestTimingMiddleware.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// RequestTimingMiddleware.cs
// Measures how long each request takes and logs it.
// ILogger<T> is a singleton-safe service — safe to inject in constructor.
// A scoped service like a DbContext would go on InvokeAsync instead.

using System.Diagnostics;

public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    // Constructor injection: only use singleton or transient services here
    public RequestTimingMiddleware(
        RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    // InvokeAsync is called by the framework for every request
    // Scoped services (e.g. AppDbContext) can be injected here as parameters
    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        // --- INCOMING: before the rest of the pipeline runs ---
        _logger.LogInformation(
            "Request started: {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        await _next(context); // run the rest of the pipeline

        // --- OUTGOING: after the response has been produced ---
        stopwatch.Stop();

        _logger.LogInformation(
            "Request finished: {Method} {Path} — {StatusCode} in {ElapsedMs}ms",
            context.Request.Method,
            context.Request.Path,
            context.Response.StatusCode,
            stopwatch.ElapsedMilliseconds);
    }
}

// MiddlewareExtensions.cs
// A clean extension method makes registration readable in Program.cs
public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestTimingMiddleware>();
    }
}

// Program.cs — how to wire it all up
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();

// Register exception handling FIRST so it wraps everything
app.UseExceptionHandler("/error");

// Our custom timing middleware — early so it times the full request
app.UseRequestTiming();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();
▶ Output
info: RequestTimingMiddleware[0]
Request started: GET /weatherforecast
info: RequestTimingMiddleware[0]
Request finished: GET /weatherforecast — 200 in 42ms
⚠ Watch Out: Scoped Services in Constructors
If you inject a scoped service (like Entity Framework's DbContext) into your middleware constructor, you'll get an InvalidOperationException at startup or, worse, a silent scope leak in production. Always inject scoped services as parameters on InvokeAsync, not the constructor. The framework resolves them from the correct per-request scope automatically.
📊 Production Insight
Injecting scoped services via constructor causes an InvalidOperationException at startup.
Even if it doesn't crash, the shared instance across requests leads to corrupted per-request data.
Rule: Always place scoped services on InvokeAsync—the framework resolves them from the request scope.
🎯 Key Takeaway
Middleware class convention: RequestDelegate in constructor, InvokeAsync(HttpContext).
Scoped services go on InvokeAsync, not constructor.
Extension methods make registration readable and follow framework patterns.

Map, Use, and Run — Choosing the Right Registration Method

ASP.NET Core gives you three core extension methods for building the pipeline, and each has a specific job. Mixing them up is a common source of subtle bugs.

'app.Use()' is the standard building block. It receives the HttpContext and a delegate to the next middleware. You call next to pass control forward. Use it for any middleware that should participate in both the incoming and outgoing phases.

'app.Run()' registers a terminal middleware — it never calls next because it's the end of the line. If you use Run and then register more middleware after it, those later registrations are silently ignored. This surprises a lot of developers.

'app.Map()' branches the pipeline based on the request path. Requests matching the path go down the branch; everything else continues on the main pipeline. It's perfect for health check endpoints, admin sections, or versioned API branches that need completely different middleware stacks. There's also 'MapWhen()' for branching on arbitrary conditions, like a header value or query string.

MiddlewareRegistrationMethods.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Program.cs — demonstrating Use, Run, and Map in a single app

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// MAP: branch the pipeline for /health — nothing else runs for this path
app.Map("/health", healthApp =>
{
    // This Run only applies inside the /health branch
    healthApp.Run(async context =>
    {
        // Lightweight health check — no auth, no logging overhead
        context.Response.ContentType = "application/json";
        await context.Response.WriteAsync("{\"status\": \"healthy\"}");
    });
});

// USE: add a header to every response NOT handled by the /health branch
app.Use(async (context, next) =>
{
    // Runs on the way out — safe to set headers here before flush
    await next(context);
    context.Response.Headers.Append("X-Powered-By", "TheCodeForge");
});

// MAPWHEN: branch based on a condition — here, requests with a specific header
app.MapWhen(
    context => context.Request.Headers.ContainsKey("X-Internal-Request"),
    internalApp =>
    {
        internalApp.Run(async context =>
        {
            await context.Response.WriteAsync("Internal route — restricted access");
        });
    });

// RUN: terminal middleware for all remaining requests
// IMPORTANT: anything registered after this is NEVER reached
app.Run(async context =>
{
    await context.Response.WriteAsync("Main application response");
});

// This middleware is DEAD CODE — app.Run above is terminal
// The framework silently ignores it. Don't do this.
app.Use(async (context, next) =>
{
    Console.WriteLine("This never executes!");
    await next(context);
});

app.Run(); // starts the web server (different Run — on WebApplication, not IApplicationBuilder)
▶ Output
GET /health → {"status": "healthy"}
GET /anything-else → "Main application response" + header X-Powered-By: TheCodeForge
GET / (X-Internal) → "Internal route — restricted access"
💡Pro Tip: Map for Micro-Pipelines
Use app.Map() when a section of your app needs genuinely different middleware. A /metrics endpoint, for example, shouldn't go through authentication middleware — it should have its own IP-allow-list check. Map lets you build a completely separate pipeline branch rather than cluttering your main pipeline with conditional logic.
📊 Production Insight
app.Run() is terminal—any middleware registered after it is silently ignored.
A common mistake is placing middleware after app.Run() and wondering why it doesn't execute.
Rule: Reserve app.Run() for the final handler; use app.Use() for middleware that should continue.
🎯 Key Takeaway
Use for two-phase middleware, Run for terminal, Map for branches.
Map creates an isolated sub-pipeline; main pipeline middleware does not run in the branch.
MapWhen branches on conditions—great for admin routes or header-based logic.

Middleware Order in Practice — The Correct ASP.NET Core Pipeline

The single most confusing thing about ASP.NET Core middleware for developers coming from other frameworks is that order is everything, and it's silent about it. There's no error if you put UseAuthorization before UseAuthentication — it just doesn't work. You get 401s or 403s that seem random until you understand the pipeline.

Microsoft documents a recommended order and it exists for good reason. Exception handling must wrap everything to catch exceptions from routing, authentication, and your own code. HTTPS redirection should happen before static files so you don't serve static assets over HTTP. Authentication must happen before Authorisation because Authorisation reads the identity that Authentication establishes. Routing must happen before Authorisation so the framework knows which endpoint's policies to check.

The table below compares the correct order against a common incorrect ordering and shows exactly what symptom you'd see. Once you understand the 'why' behind each position, the order becomes easy to remember — it's not arbitrary, it's causal.

CorrectMiddlewareOrder.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Program.cs — the recommended middleware order for a production API
// Each comment explains WHY it's in this position, not just WHAT it does

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddControllers();

var app = builder.Build();

// 1. EXCEPTION HANDLER — outermost layer, catches exceptions from everything below
//    In development, use the developer exception page for stack traces instead
if (app.Environment.IsDevelopment())
    app.UseDeveloperExceptionPage();
else
    app.UseExceptionHandler("/error");

// 2. HSTS + HTTPS REDIRECTION — security headers before any content is served
app.UseHsts();
app.UseHttpsRedirection();

// 3. STATIC FILES — served before routing to avoid routing overhead for assets
//    Static files are terminal for matched paths — they never hit your controllers
app.UseStaticFiles();

// 4. ROUTING — parses the URL and selects the endpoint
//    Must come before UseAuthentication so endpoint metadata is available
//    (used by some auth handlers to check endpoint-level attributes)
app.UseRouting();

// 5. CORS — must come after UseRouting, before UseAuthentication
//    Preflight OPTIONS requests need CORS headers before auth checks
app.UseCors("AllowFrontend");

// 6. AUTHENTICATION — reads the token/cookie and builds the ClaimsPrincipal
//    MUST come before UseAuthorization
app.UseAuthentication();

// 7. AUTHORISATION — checks if the established identity has permission
//    Relies on UseAuthentication having already run
app.UseAuthorization();

// 8. ENDPOINT EXECUTION — actually runs your controller actions / minimal API handlers
app.MapControllers();

app.Run();
▶ Output
Application starts successfully.
GET /api/secure (valid JWT) → 200 OK
GET /api/secure (no JWT) → 401 Unauthorized
GET /index.html → 200 OK (served from wwwroot, never hits routing)
GET /throws-exception → 500 with ProblemDetails JSON (caught by UseExceptionHandler)
🔥Interview Gold
If an interviewer asks 'what happens if you put UseAuthorization before UseAuthentication?' — the answer is: authorization runs before a ClaimsPrincipal has been established, so context.User is always an unauthenticated anonymous identity. Every [Authorize] attribute silently fails and returns 401, even with a valid token in the request. It's one of the hardest bugs to diagnose if you don't know the pipeline.
📊 Production Insight
Placing UseAuthorization before UseAuthentication compiles fine but always returns 401.
This silent failure is the hardest middleware bug to debug because no exception is thrown.
Rule: Always follow the recommended order—exception, HSTS, HTTPS, static, routing, CORS, auth, endpoints.
🎯 Key Takeaway
Order is causal: each layer depends on the previous.
Authentication builds ClaimsPrincipal; Authorization reads it.
Exception handler must be outermost to catch everything.

Short-Circuiting the Pipeline: When to Return Early Without Calling next()

Middleware isn't required to call next(). If it sets a response and returns without calling next, the pipeline short-circuits — inner middleware never runs, and outer middleware's post-next code also doesn't execute. This is exactly what authentication middleware does when a token is invalid: it returns 401 immediately without passing the request to your controllers.

Short-circuiting is powerful for early rejection patterns: maintenance mode checks, IP whitelisting, request size limits, and authentication failures all legitimately stop the pipeline early. But there's a trade-off: if you have timing or logging middleware registered before the short-circuiting middleware, their outgoing-phase code won't run. That means no log entry for rejected authentication attempts unless the short-circuiting middleware handles logging itself.

The decision to short-circuit should be deliberate. Use it when you know the request cannot possibly be fulfilled. But if you only want to modify the response (e.g., add a header) without affecting inner pipeline logic, always call next() first and modify the response after.

ShortCircuitExample.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334
// Program.cs — demonstrating short-circuiting middleware

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Timing middleware — logs start/end
app.Use(async (context, next) =>
{
    Console.WriteLine("Timing: Incoming");
    var sw = Stopwatch.StartNew();
    await next(context);
    sw.Stop();
    Console.WriteLine($"Timing: {sw.ElapsedMilliseconds}ms");
});

// Short-circuit middleware: block requests from certain IPs
app.Use(async (context, next) =>
{
    var blockedIPs = new[] { "192.168.1.100" };
    if (blockedIPs.Contains(context.Connection.RemoteIpAddress?.ToString()))
    {
        context.Response.StatusCode = 403;
        await context.Response.WriteAsync("Blocked");
        return; // short-circuit — no next() call
    }
    await next(context); // pass through for allowed IPs
});

app.Run(async context =>
{
    await context.Response.WriteAsync("Hello from the app!");
});

app.Run();
▶ Output
Request from allowed IP:
Timing: Incoming
Timing: 12ms (includes response generation)

Request from blocked IP 192.168.1.100:
Timing: Incoming
(No Timing output — post-next code never ran because next was never called)
Response: 403 Blocked
⚠ Short-Circuiting Breaks Outer Logging
If your timing / logging middleware is registered before a short-circuiting middleware, the 'outgoing' code (after await next()) never executes for requests that are short-circuited. Your monitors will miss those requests. To capture them, either: (1) register logging middleware after the short-circuit middleware, or (2) have the short-circuit middleware manually emit its own log entry before returning.
📊 Production Insight
Short-circuiting middleware (like authentication failure) prevents outer middleware's post-next code from running.
This means timing/ logging middleware will not record the rejected request's duration.
Rule: If you rely on outer middleware for observability, ensure short-circuiting middleware logs its own telemetry or move logging inside the short-circuit path.
🎯 Key Takeaway
Call next() to continue pipeline, or skip to short-circuit.
Short-circuiting prevents inner and outer post-pipeline code from running.
Use short-circuiting for early rejection; accept that outer middleware may miss the response.
Aspectapp.Use()app.Run()app.Map()
Calls next middlewareYes — you call next(context) explicitlyNo — terminal, never calls nextNo — branches to a sub-pipeline
Typical use caseLogging, auth, headers, timingFallback 404 handler, simple endpointsHealth checks, admin section, API versions
Sees outgoing responseYes — code after await next() runs on the way outNo — it IS the responseOnly within the branch
Silent gotchaForgetting to call next() accidentally terminates the pipelineMiddleware registered after it is silently ignoredThe branch is completely isolated — main pipeline middleware doesn't run in it
Scoped DI servicesInject via InvokeAsync parameter (class-based)N/A for inline lambdas; class-based same rule appliesSame rules apply within the branch
TestabilityHigh — extract to class, inject ILogger etc.Low for inline lambdasHigh — branch pipeline is independently testable

🎯 Key Takeaways

  • The pipeline is a two-phase stack, not a one-way conveyor — every middleware that calls next() gets to act on both the incoming request (before next) and the outgoing response (after next). This is how UseExceptionHandler and timing middleware work.
  • Order is everything and it's silent — UseAuthorization before UseAuthentication compiles and starts fine but returns 401 for every authenticated request. The recommended order exists because each layer depends on the layer above it having already run.
  • Scoped services belong on InvokeAsync parameters, not constructors — middleware instances live for the lifetime of the app (singleton-ish), so injecting a scoped DbContext into the constructor causes a scope capture bug. The InvokeAsync parameter injection is the correct, framework-supported pattern.
  • app.Run() silently swallows everything registered after it — it's terminal. If you're debugging middleware that seems to never run, check whether an app.Run() call appears before it in Program.cs.
  • Short-circuiting middleware (e.g., authentication failure) prevents outer middleware's post-next code from running — account for this in logging/timing middleware registering before the short-circuit point.
  • Extension methods (UseMiddleware<T>) encapsulate registration logic and improve testability — always use them for custom middleware classes.

⚠ Common Mistakes to Avoid

    Putting UseAuthorization before UseAuthentication
    Symptom

    Every request returns 401 even with a valid JWT or cookie, regardless of the [Authorize] policy. No error in logs.

    Fix

    Always place UseAuthentication() immediately before UseAuthorization(). Authentication sets up context.User; Authorisation reads it.

    Injecting a scoped service (e.g., DbContext) into the middleware constructor
    Symptom

    InvalidOperationException at startup 'Cannot consume scoped service from singleton', or worse, no exception but a shared DbContext across requests causing data corruption in production.

    Fix

    Add the scoped service as a parameter on InvokeAsync(HttpContext context, MyDbContext db) — the framework resolves it from the correct per-request scope.

    Registering middleware after app.Run()
    Symptom

    Middleware registered after app.Run() is completely silently ignored. No warning, no exception.

    Fix

    Remember that app.Run() is terminal — it never calls next. Any middleware that should run for all requests must be registered before app.Run(). Use app.Use() for non-terminal middleware and reserve app.Run() for the very last handler only.

Interview Questions on This Topic

  • QCan you explain the ASP.NET Core request pipeline and why middleware order matters? Give a concrete example of what breaks when the order is wrong.Mid-levelReveal
    The pipeline is a chain of middleware delegates. Each middleware can perform work before and after calling the next delegate. Order is critical because each middleware depends on the state established by previous ones. For example, if you place UseAuthorization before UseAuthentication, the authorization middleware will find an unauthenticated identity and return 401 for every request, even with a valid token. This compiles without error and runs without exceptions — making it one of the hardest bugs to diagnose. The correct order is: exception handling, HTTPS redirection, static files, routing, CORS, authentication, authorization, and finally endpoint execution.
  • QHow does dependency injection work differently in a middleware constructor versus the InvokeAsync method, and why does that difference exist?SeniorReveal
    Middleware instances are created once when the application starts — they live for the application's lifetime (effectively singleton scope). Therefore, the constructor can only receive singleton or transient services. Scoped services (like Entity Framework DbContext) cannot be injected via the constructor; doing so causes an InvalidOperationException at startup. The framework provides a mechanism to inject scoped services as parameters on the InvokeAsync method. This works because InvokeAsync is called per-request, so the framework can resolve scoped services from the current request's DI scope. This design prevents scope capture bugs where a singleton middleware holds a reference to a per-request service across multiple requests, causing data corruption.
  • QWhat's the difference between app.Use(), app.Run(), and app.Map()? If I register middleware after app.Run(), what happens and why?JuniorReveal
    app.Use() adds middleware that can call next to continue the pipeline — it participates in both the incoming and outgoing phases. app.Run() adds terminal middleware that never calls next; it represents the end of the pipeline for that branch. app.Map() creates a branch in the pipeline based on a request path prefix, building an independent sub-pipeline. If you register middleware after app.Run(), it is silently ignored because app.Run() is terminal — the pipeline never reaches subsequent middlewares. This is a common source of confusion because no error is raised.

Frequently Asked Questions

What is middleware in ASP.NET Core and how is it different from filters?

Middleware operates at the HTTP pipeline level — it sees every request before routing even happens, and it can short-circuit the pipeline entirely. Filters (Action filters, Exception filters etc.) operate within the MVC layer and only run for requests that reach a controller action. Use middleware for cross-cutting concerns that apply to all requests (logging, authentication, CORS). Use filters for MVC-specific logic like validating model state or handling exceptions inside controller actions.

Can I run async code inside middleware?

Yes — and you should. InvokeAsync returns a Task, and you use async/await throughout. The critical rule is to await the call to next(context) rather than calling it synchronously. Never call next(context).Wait() or .Result — that causes thread starvation under load by blocking the thread pool.

How do I conditionally skip a middleware for certain routes?

The cleanest approach is app.MapWhen() to branch the pipeline based on the path or any condition, directing matched requests to a sub-pipeline that doesn't include the middleware you want to skip. Alternatively, inside your middleware you can check context.Request.Path and call next() immediately without doing your work. For endpoint-specific exclusions, some middleware (like UseAuthorization) respects the [AllowAnonymous] attribute, which is a cleaner pattern than path checking in middleware code.

How do I unit test custom middleware?

Create a DefaultHttpContext, set up the request properties, instantiate your middleware with a mock RequestDelegate, and call InvokeAsync. Assert on the response status code or headers. For integration tests, use WebApplicationFactory<Program> with TestServer, sending HTTP requests through the full pipeline and verifying behavior. The key is to test both the normal path (middleware calls next and the response is produced) and the short-circuit path (middleware returns without calling next).

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousREST API with ASP.NET CoreNext →Entity Framework Core Basics
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged