When you build an ASP.NET Core application, your controllers and services get all the glory. But before an HTTP request ever reaches your AccountController, and after it generates a response, it must travel through the true engine of ASP.NET Core: The Middleware Pipeline.
If you don't understand middleware, you don't truly understand ASP.NET Core.
In this article, we will demystify how the request pipeline works, why the order of execution is critical, and how to write custom middleware to handle cross-cutting concerns—like the API Key validation system we built in our previous architecture.
1. What is Middleware?
Think of the middleware pipeline like an assembly line in a factory, or a series of water filters.
When an HTTP Request enters your application, it passes through a sequence of components (Middleware). Each component has three choices:
Examine/Modify the Request: Tweak the headers, log the incoming data, or start a stopwatch.
Pass it to the Next Component: Hand the request down the line by calling await next(context).
Short-Circuit the Pipeline: Decide the request is invalid (e.g., missing an API key) and immediately return an HTTP Response, preventing the rest of the application from ever seeing the request.
Once the request reaches the end of the pipeline (usually your Controller endpoints), the HTTP Response travels backwards through the exact same middleware components before leaving the server.
2. The Golden Rule: Order Matters
Because each middleware wraps the next one, the order in which you register middleware in Program.cs dictates your application's behavior and security.
If you put your Authorization middleware before your Authentication middleware, your app will crash or fail securely because it tries to check a user's permissions before it even knows who the user is.
Here is the standard, battle-tested order for a secure ASP.NET Core web API:
C#
var app = builder.Build();
// 1. Global Exception Handling (Must be first to catch errors from all subsequent middleware)
if (!app.Environment.IsDevelopment()) {
app.UseExceptionHandler("/error");
app.UseHsts();
}
// 2. HTTPS Redirection (Force secure connections early)
app.UseHttpsRedirection();
// 3. Static Files (Serve images/CSS without hitting routing logic)
app.UseStaticFiles();
// 4. Routing (Figure out which Controller/Endpoint the request wants)
app.UseRouting();
// 5. CORS (Apply Cross-Origin policies before Auth)
app.UseCors();
// 6. Authentication (Who are you?)
app.UseAuthentication();
// 7. Authorization (Are you allowed to be here?)
app.UseAuthorization();
// 8. Custom Middleware (e.g., API Key Validation, Tenant Resolution)
app.UseMiddleware<ApiKeyMiddleware>();
// 9. Endpoints (Execute the Controller logic)
app.MapControllers();
app.Run();
3. Writing Custom Middleware
While Microsoft provides excellent built-in middleware for Routing and Auth, you will inevitably need to write your own to handle business-specific logic.
Let's look at the API Key architecture we built earlier. Instead of checking if a valid API Key exists inside every single Controller method, we can write a custom Middleware to intercept the request and validate the key globally.
The Custom API Key Middleware
Create a new class called ApiKeyMiddleware.cs:
C#
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NTL_OneClick_DI_Domain.Interfaces;
namespace NTL_OneClick_DI_Web.Middleware
{
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ApiKeyMiddleware> _logger;
private const string API_KEY_HEADER = "X-API-Key";
// Constructor runs ONCE when the app starts
public ApiKeyMiddleware(RequestDelegate next, ILogger<ApiKeyMiddleware> logger)
{
_next = next;
_logger = logger;
}
// InvokeAsync runs on EVERY HTTP request
public async Task InvokeAsync(HttpContext context, IClientAuthRepository authRepo)
{
// 1. Bypass check for specific endpoints (like Login or Swagger)
if (context.Request.Path.StartsWithSegments("/api/public") ||
context.Request.Path.StartsWithSegments("/Account/Login"))
{
await _next(context); // Pass to the next middleware
return;
}
// 2. Check if the header exists
if (!context.Request.Headers.TryGetValue(API_KEY_HEADER, out var extractedApiKey))
{
_logger.LogWarning("API Key missing from request headers.");
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { Error = "API Key is missing." });
return; // SHORT-CIRCUIT: Do not call _next()
}
// 3. Validate the key against the database
var client = await authRepo.GetClientByApiKeyAsync(extractedApiKey!);
if (client == null)
{
_logger.LogWarning("Invalid API Key provided: {ApiKey}", extractedApiKey);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new { Error = "Invalid or expired API Key." });
return; // SHORT-CIRCUIT
}
// 4. Success! Attach the ClientId to the HTTP Context for downstream controllers to use
context.Items["ValidatedClientId"] = client.Id;
// 5. Pass the request down the pipeline
await _next(context);
}
}
}
To activate this, simply add app.UseMiddleware<ApiKeyMiddleware>(); to your Program.cs.
4. The Dependency Injection Trap in Middleware
If you look closely at the ApiKeyMiddleware above, you will notice something peculiar about Dependency Injection (DI).
Why? Middleware is constructed once per application lifetime (it is a Singleton). If you inject a Scoped service (like your Entity Framework ApplicationDbContext or IClientAuthRepository) into the constructor of a Singleton, that DbContext will stay alive forever. It will become a bottleneck, share data across different users' requests, and eventually crash with concurrency errors.
The Fix: Always inject Scoped or Transient services directly into the InvokeAsync method. ASP.NET Core's DI container is smart enough to resolve them on a per-request basis, keeping your database contexts safely scoped to the individual HTTP request.
5. Short-Circuiting vs. Wrapping
Our API Key example demonstrates Short-Circuiting—if the key is invalid, we return a 401 Unauthorized and stop the pipeline dead in its tracks.
However, middleware is also used for Wrapping requests. A great example is a Global Exception Handler.
C#
public async Task InvokeAsync(HttpContext context)
{
try
{
// Pass the request down the pipeline
await _next(context);
}
catch (Exception ex)
{
// The request hit a controller, blew up, and the error bubbled back up to here!
_logger.LogError(ex, "An unhandled exception occurred during the request.");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { Error = "Internal Server Error" });
}
}
Because the await _next(context) is wrapped in a try/catch block, any exception thrown by any controller or service further down the pipeline will bubble back up, be caught by this middleware, and be transformed into a graceful 500 JSON response rather than a nasty yellow screen of death.
Conclusion
Middleware is the central nervous system of ASP.NET Core. By understanding how the pipeline flows, respecting the order of operations, and mastering Dependency Injection lifecycles, you unlock the ability to handle cross-cutting concerns elegantly.
Whether you are logging performance, managing database transactions, handling global errors, or validating custom API keys, extracting that logic out of your Controllers and into Custom Middleware keeps your architecture clean, secure, and highly maintainable.