Middleware are components assembled into a pipeline that processes every HTTP request and response. Each component can execute logic before and after calling the next component, inspect or mutate the request/response, or short-circuit the pipeline by not calling the next component.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Each Use() adds a middleware component to the pipeline:
app.Use(async (context, next) =>
{
// Before: runs on the way IN (request)
Console.WriteLine($"→ {context.Request.Path}");
await next(context); // call the next middleware
// After: runs on the way OUT (response)
Console.WriteLine($"← {context.Response.StatusCode}");
});
app.MapGet("/", () => "Hello World");
app.Run(); // starts the pipeline
The pipeline is a chain of delegates — each middleware receives an
HttpContext and a RequestDelegate (next). The ASP.NET Core host
calls the first middleware; it calls next, which calls the second, and so on.
The response flows back through each component in reverse order.
Rule of thumb: Think of the middleware pipeline as an onion — request goes inward through each layer, response comes back outward through the same layers in reverse.
Use— adds a middleware that callsnextto continue the pipeline.Run— adds a terminal middleware that never callsnext; it ends the pipeline.Map— branches the pipeline for requests matching a path prefix; the branch runs independently and does not rejoin the main pipeline.
app.Use(async (context, next) =>
{
// Runs for ALL requests; calls next to continue
context.Items["start"] = DateTime.UtcNow;
await next(context);
});
app.Map("/health", healthApp =>
{
// Branch: only requests to /health enter here
healthApp.Run(async context =>
{
// Terminal: returns immediately; never calls next
await context.Response.WriteAsync("OK");
});
});
// Main pipeline continues for non-/health requests:
app.Run(async context =>
{
// Terminal middleware for everything else
await context.Response.WriteAsync("Hello");
});
MapWhen is a more flexible variant that branches on any predicate:
app.MapWhen(
ctx => ctx.Request.Headers.ContainsKey("X-Special"),
specialApp => specialApp.Run(async ctx =>
await ctx.Response.WriteAsync("special path")));
Rule of thumb: Use Use for cross-cutting concerns that must wrap all
requests. Use Run only at the end of a pipeline branch. Use Map to isolate
sub-pipelines for specific path prefixes.
Middleware runs in registration order on the request path and in reverse order on the response path. Ordering determines which component sees the request first and which wraps which. Getting it wrong causes security holes or broken behavior.
// Microsoft's recommended ordering for a typical web API:
app.UseExceptionHandler("/error"); // 1. Catch all unhandled exceptions
app.UseHsts(); // 2. Add Strict-Transport-Security header
app.UseHttpsRedirection(); // 3. Redirect HTTP → HTTPS
app.UseStaticFiles(); // 4. Serve static files (no auth needed)
app.UseRouting(); // 5. Match routes (populates endpoint metadata)
app.UseCors(); // 6. Apply CORS policy (must be after routing)
app.UseAuthentication(); // 7. Identify the user
app.UseAuthorization(); // 8. Enforce access rules
app.UseOutputCache(); // 9. Cache responses (must be after auth)
app.MapControllers(); // 10. Dispatch to controller actions
Wrong ordering examples:
UseAuthenticationbeforeUseRoutingmeans auth runs even for unmatched routes.UseCorsbeforeUseRoutingmeans CORS cannot read endpoint metadata to apply per-endpoint policies.UseStaticFilesafterUseAuthenticationapplies auth to static asset requests (usually unnecessary overhead).
Rule of thumb: Exception handling goes first. Static files go before auth. Routing before CORS. Auth before authorization. Dispatch (MapControllers) goes last.
Two styles: convention-based (POCO class) and interface-based (IMiddleware).
Convention-based is more common and requires no registration in DI as a singleton.
// Convention-based: constructor takes RequestDelegate; InvokeAsync takes HttpContext
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();
await _next(context); // call the rest of the pipeline
sw.Stop();
_logger.LogInformation("{Method} {Path} completed in {Ms} ms",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds);
}
}
// Extension method for clean registration:
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(
this IApplicationBuilder app) =>
app.UseMiddleware<RequestTimingMiddleware>();
}
// In Program.cs:
app.UseRequestTiming();
The IMiddleware style is created per-request from DI (supports scoped dependencies):
public class ScopedMiddleware : IMiddleware
{
private readonly IScopedService _svc; // scoped dep — works with IMiddleware
public ScopedMiddleware(IScopedService svc) => _svc = svc;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
_svc.DoSomething();
await next(context);
}
}
// Must be registered in DI:
builder.Services.AddTransient<ScopedMiddleware>();
app.UseMiddleware<ScopedMiddleware>();
Rule of thumb: Use convention-based middleware for singleton / transient
dependencies. Use IMiddleware when you need scoped services injected directly
into the middleware constructor.
Short-circuiting means a middleware does not call next — it handles the
request entirely and writes the response itself, preventing later middleware from
running. This is how authentication middleware rejects unauthorized requests before
they reach the controller.
app.Use(async (context, next) =>
{
// Check API key in header
if (!context.Request.Headers.TryGetValue("X-Api-Key", out var key)
|| key != "secret-key")
{
// Short-circuit: respond 401 without calling next
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Unauthorized");
return; // ← critical: do NOT call next
}
await next(context); // valid key — continue to next middleware
});
// Controller only runs if the API key was valid:
app.MapControllers();
Common scenarios for short-circuiting:
- Authentication / authorization failures — return 401 / 403.
- Health checks — return 200 OK immediately without running the full pipeline.
- Rate limiting — return 429 Too Many Requests.
- CORS preflight — handle OPTIONS requests without business logic.
- Request validation — return 400 Bad Request for malformed input.
Rule of thumb: Short-circuit as early in the pipeline as possible to avoid
unnecessary work. Always set the correct HTTP status code before returning without
calling next.
ASP.NET Core provides two built-in options: UseExceptionHandler for production
(re-executes a different endpoint) and UseDeveloperExceptionPage for development
(shows a detailed stack trace). You can also write your own.
if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); // shows full stack trace — dev only!
else
app.UseExceptionHandler("/error"); // re-executes /error endpoint — production
// /error endpoint reads the stored exception from HttpContext:
app.Map("/error", (HttpContext ctx) =>
{
var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
return Results.Problem(
title: "An error occurred",
detail: ex?.Message,
statusCode: 500);
});
Custom exception middleware — catches exceptions from any later middleware:
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
public GlobalExceptionMiddleware(RequestDelegate next,
ILogger<GlobalExceptionMiddleware> logger)
{
_next = next; _logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context); // run the rest of the pipeline
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception for {Path}",
context.Request.Path);
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(
new { error = "An unexpected error occurred." });
}
}
}
Rule of thumb: Always register exception-handling middleware first in the pipeline so it wraps every subsequent component and can catch any unhandled exception.
Middleware operates at the HTTP pipeline level — it sees raw HttpContext and
runs for every request, including static files, health checks, and non-MVC routes.
Action filters are part of the MVC framework and run only for controller actions;
they have access to ActionContext, model binding results, and action arguments.
// Middleware: applies to every request, no MVC context
app.Use(async (ctx, next) =>
{
// ctx.Request, ctx.Response — raw HTTP only
// No knowledge of controllers, actions, or models
await next(ctx);
});
// Action filter: runs only for matched MVC endpoints
public class ValidateModelFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
// Has access to model state, action arguments, controller
if (!context.ModelState.IsValid)
context.Result = new BadRequestObjectResult(context.ModelState);
}
public void OnActionExecuted(ActionExecutedContext context) { }
}
// Register globally:
builder.Services.AddControllers(options =>
options.Filters.Add<ValidateModelFilter>());
| Aspect | Middleware | Action Filter |
|---|---|---|
| Scope | All requests | MVC actions only |
| Context | HttpContext |
ActionContext, model state |
| Ordering | Pipeline order | Filter pipeline (Auth→Resource→Action→Result) |
| Best for | CORS, logging, auth | Validation, audit, action-level cross-cutting |
Rule of thumb: Use middleware for concerns that apply to all HTTP traffic (logging, CORS, auth, rate limiting). Use filters when you need MVC context (model state, action arguments, controller metadata).
ASP.NET Core ships with a rich set of built-in middleware for common scenarios:
app.UseExceptionHandler("/error"); // Global exception handling
app.UseHsts(); // HTTP Strict Transport Security
app.UseHttpsRedirection(); // HTTP → HTTPS redirect
app.UseStaticFiles(); // Serve wwwroot static assets
app.UseRouting(); // Endpoint routing
app.UseCors("MyPolicy"); // Cross-Origin Resource Sharing
app.UseAuthentication(); // JWT / cookie auth
app.UseAuthorization(); // Policy enforcement
app.UseRateLimiter(); // Rate limiting (.NET 7+)
app.UseOutputCache(); // Response caching (.NET 7+)
app.UseResponseCompression(); // Gzip / Brotli compression
app.UseSession(); // Session state
app.UseRequestLocalization(); // i18n / locale
The UseStaticFiles middleware serves files from wwwroot by default:
// Serve files from a custom directory:
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "uploads")),
RequestPath = "/uploads"
});
Rule of thumb: Rely on built-in middleware before writing custom components — they are battle-tested, well-configured, and maintained by Microsoft. Only write custom middleware for genuinely application-specific concerns.
In convention-based middleware the middleware class is instantiated once (singleton
lifetime). Singleton services can be injected via the constructor. Scoped and
transient services must be injected via InvokeAsync parameters — ASP.NET Core
resolves them from the per-request scope automatically.
public class AuditMiddleware
{
private readonly RequestDelegate _next;
private readonly IConfiguration _config; // singleton — safe in constructor
public AuditMiddleware(RequestDelegate next, IConfiguration config)
{
_next = next;
_config = config;
}
// Scoped service injected via parameter — fresh instance per request:
public async Task InvokeAsync(HttpContext context, IAuditService auditService)
{
await auditService.LogRequestAsync(context.Request.Path);
await _next(context);
}
}
Injecting a scoped service into the constructor of convention-based middleware creates a captive dependency — the scoped service lives for the entire app lifetime, causing shared state bugs across requests.
Rule of thumb: Singleton deps → constructor. Scoped / transient deps →
InvokeAsync parameters. Alternatively, use IMiddleware which is resolved
per-request and supports any lifetime in the constructor.
UseResponseCaching (all .NET versions) adds standard HTTP cache headers
(Cache-Control) and caches responses in memory that have those headers. It only
caches GET/HEAD requests with successful 2xx status codes and does not cache
when the request includes Authorization headers by default.
// Startup:
builder.Services.AddResponseCaching();
app.UseResponseCaching(); // must be before routing
// Controller action:
[HttpGet("products")]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public IActionResult GetProducts() => Ok(_products);
// Sets: Cache-Control: public, max-age=60
UseOutputCache (.NET 7+) is a newer, more powerful replacement:
builder.Services.AddOutputCache(options =>
options.AddPolicy("products", p => p.Expire(TimeSpan.FromMinutes(1))));
app.UseOutputCache();
app.MapGet("products", () => products).CacheOutput("products");
// Supports cache invalidation, vary-by-query, tags, and custom stores
| Feature | ResponseCaching | OutputCache |
|---|---|---|
| Standard | HTTP/1.1 Cache-Control | Server-side only |
| Invalidation | None | Tag-based |
| Vary-by | Headers | Query, headers, claims, custom |
| .NET version | All | .NET 7+ |
Rule of thumb: For new projects on .NET 7+, prefer UseOutputCache — it gives
fine-grained control, tag-based invalidation, and works correctly with authenticated
endpoints. Use UseResponseCaching only for simple public CDN-friendly responses.
CORS (Cross-Origin Resource Sharing) is enforced by the browser for
cross-origin requests. The ASP.NET Core CORS middleware adds the necessary
Access-Control-Allow-* response headers.
// Register CORS with a named policy:
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
policy.WithOrigins("https://app.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.AllowCredentials());
// Development: allow any origin (NEVER use in production):
options.AddPolicy("DevAllowAll", policy =>
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});
// Apply: MUST be between UseRouting and UseAuthorization
app.UseRouting();
app.UseCors("AllowFrontend");
app.UseAuthorization();
// Per-endpoint override:
app.MapGet("/public", () => "public")
.RequireCors("DevAllowAll");
// Disable CORS for a specific endpoint:
app.MapGet("/internal", () => "internal")
.RequireCors(builder => builder.DisallowCredentials());
The preflight OPTIONS request is handled automatically by UseCors — you do not
need a separate OPTIONS action in your controller.
Rule of thumb: Define tight CORS policies per environment — never use
AllowAnyOrigin with AllowCredentials (the spec forbids it and ASP.NET Core
throws). Register UseCors between UseRouting and UseAuthorization.
A request travels through the pipeline in a strict sequence:
HTTP Request
│
▼
┌─────────────────────────┐
│ ExceptionHandler │ ← wraps all; catches any exception
│ ┌───────────────────┐ │
│ │ HSTS / HTTPS │ │ ← security headers
│ │ ┌─────────────┐ │ │
│ │ │ StaticFiles │ │ │ ← served without auth
│ │ │ ┌────────┐ │ │ │
│ │ │ │Routing │ │ │ │ ← matches endpoint, populates metadata
│ │ │ │ ┌───┐ │ │ │ │
│ │ │ │ │Auth│ │ │ │ │ ← authn + authz
│ │ │ │ │ ┌─┐│ │ │ │ │
│ │ │ │ │ │EP│ │ │ │ │ ← endpoint (controller action / minimal API)
│ │ │ │ │ └─┘│ │ │ │ │
│ │ │ │ └───┘ │ │ │ │
│ │ │ └────────┘ │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────┘ │
└─────────────────────────┘
│
▼
HTTP Response (flows back outward through each layer)
Each box represents a middleware. The response flows back outward through each layer in reverse, allowing each component to inspect or modify the response.
// Simplified but accurate lifecycle in code:
app.UseExceptionHandler("/error"); // outer: catches exceptions
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); // inner: business logic
Rule of thumb: The request flows inward (top → bottom in registration order), the response flows outward (bottom → top). Components registered early have the most control and the most responsibility.
Rate limiting (.NET 7+) is built into ASP.NET Core via Microsoft.AspNetCore.RateLimiting.
You define one or more policies, register them with AddRateLimiter, and apply the
middleware with UseRateLimiter.
using System.Threading.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
// Fixed window: max 100 requests per 60-second window per client IP
options.AddFixedWindowLimiter("fixed", o =>
{
o.PermitLimit = 100;
o.Window = TimeSpan.FromSeconds(60);
o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
o.QueueLimit = 10; // queue up to 10 excess requests
});
// Sliding window — smoother than fixed; avoids burst at window boundary
options.AddSlidingWindowLimiter("sliding", o =>
{
o.PermitLimit = 100;
o.Window = TimeSpan.FromSeconds(60);
o.SegmentsPerWindow = 6; // 10-second segments
o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
o.QueueLimit = 0;
});
// Token bucket — allows bursts up to bucket size
options.AddTokenBucketLimiter("api", o =>
{
o.TokenLimit = 50;
o.TokensPerPeriod = 10;
o.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
o.QueueLimit = 0;
});
// 429 response when limit exceeded:
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Register in pipeline AFTER UseRouting:
app.UseRateLimiter();
// Apply a policy to a specific endpoint:
app.MapGet("/api/data", () => "data")
.RequireRateLimiting("fixed");
// Apply globally to all controllers:
app.MapControllers().RequireRateLimiting("api");
// Disable on a specific action:
app.MapGet("/health", () => "OK")
.DisableRateLimiting();
Rule of thumb: Prefer AddTokenBucketLimiter for APIs that need burst tolerance,
and AddSlidingWindowLimiter for smooth, predictable throttling. Always set
RejectionStatusCode = 429 and consider partition by user or IP for fairness.
Both UseWhen and MapWhen branch the middleware pipeline based on a predicate, but
they differ in whether the branch rejoins the main pipeline:
UseWhen— runs extra middleware for matching requests, then rejoins the main pipeline after the branch, so subsequent middleware still runs.MapWhen— creates a completely separate branch; matching requests never return to the main pipeline.
// UseWhen: add logging only for API requests, then continue main pipeline
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/api"),
apiApp =>
{
apiApp.Use(async (context, next) =>
{
Console.WriteLine($"API request: {context.Request.Method} {context.Request.Path}");
await next(context); // continue branch
});
// After the branch, the main pipeline continues — MapControllers still runs
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); // reached by ALL requests (both API and non-API)
// MapWhen: isolate /health — it never hits auth or controllers
app.MapWhen(
ctx => ctx.Request.Path == "/health",
healthApp =>
{
// Terminal for /health requests — does NOT rejoin main pipeline
healthApp.Run(async ctx =>
await ctx.Response.WriteAsync("Healthy"));
});
// Good: UseWhen for cross-cutting concerns that still need the main pipeline
// Good: MapWhen for isolated sub-pipelines (health, metrics, webhooks)
Rule of thumb: Use UseWhen when you want conditional middleware but still need
the rest of the pipeline (auth, routing, dispatch) to run. Use MapWhen when the
branch is fully self-contained and must not fall through to the main pipeline.
Endpoint filters (ASP.NET Core 7+) are the minimal API equivalent of MVC action filters. They wrap a minimal API handler and can run logic before and after it executes, short-circuit with a different result, or modify the response.
// Inline endpoint filter:
app.MapPost("/orders", CreateOrder)
.AddEndpointFilter(async (ctx, next) =>
{
// Before handler — validate request:
if (!ctx.HttpContext.User.Identity!.IsAuthenticated)
return Results.Unauthorized();
var result = await next(ctx); // call the handler
// After handler — inspect/modify result:
return result;
});
// Reusable filter class:
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
// Find the first argument of type T:
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (arg is null)
return Results.BadRequest("Missing request body");
// Validate with DataAnnotations:
var errors = new List<ValidationResult>();
if (!Validator.TryValidateObject(arg, new ValidationContext(arg), errors, true))
return Results.ValidationProblem(
errors.ToDictionary(e => e.MemberNames.First(), e => new[] { e.ErrorMessage! }));
return await next(ctx); // all valid — run handler
}
}
// Apply typed validation filter to an endpoint:
app.MapPost("/products", (CreateProductDto dto) => CreateProduct(dto))
.AddEndpointFilter<ValidationFilter<CreateProductDto>>();
// Apply to an entire group:
var orders = app.MapGroup("/orders").AddEndpointFilter<ValidationFilter<CreateOrderDto>>();
orders.MapPost("/", CreateOrder);
orders.MapPut("/{id:int}", UpdateOrder);
| Aspect | Endpoint Filter | Action Filter |
|---|---|---|
| Target | Minimal API endpoints | MVC controller actions |
| Context | EndpointFilterInvocationContext |
ActionExecutingContext |
| Groups | Yes (MapGroup) |
Global / controller / action |
| Model state | Manual validation | Automatic with [ApiController] |
Rule of thumb: Use endpoint filters in minimal APIs for cross-cutting concerns
(validation, logging, rate limiting). Use action filters in controller-based APIs
where you need access to ModelState, action descriptors, or MVC conventions.
More ASP.NET Core interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.