How to Create Custom Middlewares in ASP.NET Core (Step by Step)
Learn to build custom middleware in ASP.NET Core three ways: inline Use, a convention class, and IMiddleware with DI. Beginner friendly, with clear examples.
A line of security checks at the airport
Think about walking into an airport before a flight. You do not go straight to the plane. You pass through a line of checks, one after another, in a fixed order.
First, a guard checks your ticket at the door. Next, someone checks your ID. Then your bag goes through the scanner. After that you walk through the metal detector. Only then do you reach the gate and board the plane. Each helper does one small job and then sends you to the next helper in line.
And when you land and come back out, you pass through some of the same kinds of checks again, in reverse. Many hands, one fixed order, in and back out.
Middleware in ASP.NET Core works in this exact way. Every web request that reaches your app is like a traveller at the airport. It walks through a line of helpers called middleware. The built-in helpers do common jobs like routing and authentication. But sometimes you need your own check that the airport does not provide. That is custom middleware, and building it is what this guide is about.
What is custom middleware?
Custom middleware is a small piece of code that you write and plug into the request pipeline. It is no different in shape from the built-in middleware. It just does the job you need.
Here is what a custom middleware can do:
- Look at the request before it goes deeper (read a header, log the path).
- Decide whether to call the next middleware or stop right there.
- Do work on the response on the way back (add a header, measure the time taken).
Because each middleware can act both on the way in and on the way out, the pipeline is shaped like nested boxes. Your custom middleware becomes one of those boxes. Here is the simple flow.
Notice the two most important parts. There is a before part (when the request is going in) and an after part (when the response is coming back). And there is the word next, which means "hand the request to the next helper in line." Every way of writing middleware uses these same two ideas.
The three ways to build it
There are three common ways to create custom middleware in ASP.NET Core. They all reach the same pipeline, but they differ in how much structure and power they give you.
| Way | Where you write it | Best for | Created |
|---|---|---|---|
Inline app.Use | A lambda in Program.cs | Tiny, quick jobs | Once at startup |
| Convention-based class | A separate class with InvokeAsync | Reusable logic, simple needs | Once at startup (singleton) |
Factory-based IMiddleware | A class implementing IMiddleware | When you need scoped services | Once per request |
Let us walk through each one, from the simplest to the most powerful. We will keep the airport picture in mind the whole time.
Choosing your middleware style
Steps
Tiny job?
Use inline app.Use
Reusable?
Write a convention class
Need scoped service?
Use IMiddleware + DI
Way 1: Inline middleware with app.Use
The quickest way is to write the middleware right inside Program.cs. You call app.Use and pass a small function. This function gets two things: the HttpContext (everything about the current request and response) and next (a way to call the next middleware).
Here is a custom middleware that measures how long each request takes and writes it to the console.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(async (context, next) =>
{
// BEFORE: this runs on the way in
var start = DateTime.UtcNow;
// Hand the request to the next helper in line
await next(context);
// AFTER: this runs on the way back out
var ms = (DateTime.UtcNow - start).TotalMilliseconds;
Console.WriteLine($"{context.Request.Path} took {ms} ms");
});
app.MapGet("/", () => "Hello from the endpoint!");
app.Run();Read it slowly. The code before await next(context) runs when the request is going in. Then next passes control deeper into the pipeline. When everything deeper has finished, control comes back, and the code after next runs. That is the in-and-out shape again.
One rule to remember: if you do not call next, the request stops at your middleware. That is sometimes what you want (for example, blocking a request). But if you forget it by mistake, your app will look broken because requests never reach the endpoint.
Inline middleware is great for tiny jobs. But when the logic grows, or you want to reuse it across projects, a class is cleaner.
Way 2: Convention-based middleware class
The second way is to move your logic into its own class. ASP.NET Core does not make you implement any interface here. Instead it looks for a class that follows a simple naming convention:
- A public constructor that takes a
RequestDelegate(this is thenext). - A public method named
InvokeorInvokeAsyncthat takes anHttpContextand returns aTask.
Here is the same timing logic written as a convention-based class.
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
// RequestDelegate is "next". It is given to us once at startup.
public RequestTimingMiddleware(
RequestDelegate next,
ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var start = DateTime.UtcNow;
await _next(context); // call the next helper
var ms = (DateTime.UtcNow - start).TotalMilliseconds;
_logger.LogInformation("{Path} took {Ms} ms",
context.Request.Path, ms);
}
}To plug this into the pipeline, you call UseMiddleware in Program.cs:
app.UseMiddleware<RequestTimingMiddleware>();Many teams like to wrap that call in a small extension method so it reads nicely. This is a common, friendly pattern.
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(
this IApplicationBuilder app)
{
return app.UseMiddleware<RequestTimingMiddleware>();
}
}
// In Program.cs it now reads like plain English:
// app.UseRequestTiming();There is one important thing to understand about a convention-based class. ASP.NET Core builds it once, when your app starts. The same single object handles every request after that. In other words, it behaves like a singleton.
This is fine for the constructor dependencies above, because ILogger lives for the whole app. But it causes a real problem if you try to inject a scoped service (like a database context) into the constructor. A scoped service is meant to live for one request only. Putting it in a singleton's constructor would freeze it for the whole app, which is wrong and unsafe.
The good news: you can still get scoped services in a convention-based class. You just take them as parameters on InvokeAsync instead of the constructor, because InvokeAsync runs per request.
// Scoped services go on InvokeAsync, not the constructor
public async Task InvokeAsync(HttpContext context, IMyScopedService svc)
{
await svc.DoWorkAsync();
await _next(context);
}Convention-based lifetime
Steps
App starts
Pipeline is set up
Object built once
Constructor runs a single time
Request arrives
Same object reused
InvokeAsync runs
Scoped services injected here
Way 3: Factory-based middleware with IMiddleware
The third way fixes the scoped-service worry in a clean, modern manner. You write a class that implements the IMiddleware interface. ASP.NET Core then uses a factory to build a fresh copy of your middleware for every request.
Because a new object is made per request, you can safely inject scoped services right into the constructor. This is the biggest difference between the two class styles, and it is the main reason to choose IMiddleware.
public class CorrelationIdMiddleware : IMiddleware
{
private readonly IRequestStore _store; // a scoped service
// Built fresh each request, so scoped DI in the constructor is safe
public CorrelationIdMiddleware(IRequestStore store)
{
_store = store;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
// BEFORE: give every request a tracking id
var id = Guid.NewGuid().ToString("N");
_store.CorrelationId = id;
context.Response.Headers["X-Correlation-Id"] = id;
await next(context); // call the next helper
}
}Notice that next is now a parameter on InvokeAsync, not a constructor argument. That is part of the IMiddleware shape.
There is one extra step with this style. Because a factory builds it, you must register the middleware in dependency injection before you use it. If you forget this step, the app throws an error at startup telling you the type was not found in the container.
// Register in DI (AddScoped or AddSingleton, your choice)
builder.Services.AddScoped<CorrelationIdMiddleware>();
// Then add it to the pipeline like before
app.UseMiddleware<CorrelationIdMiddleware>();Here is how the factory builds your middleware each time a request comes in.
Comparing the two class styles
Both class styles look similar at first, so this table makes the real differences clear.
| Feature | Convention-based | Factory-based (IMiddleware) |
|---|---|---|
| Implements an interface | No (naming only) | Yes (IMiddleware) |
| When it is created | Once at startup | Once per request |
| Acts like | Singleton | Per-request object |
| Scoped DI in constructor | Not safe | Safe |
Where next lives | Constructor (RequestDelegate) | InvokeAsync parameter |
| Must register in DI | No | Yes |
| Strongly typed | Less | More (compiler checks the interface) |
A simple rule of thumb: start with a convention-based class for most jobs. Reach for IMiddleware the moment you need a scoped service like a database context or a per-request store inside the middleware itself.
Order is everything
No matter which style you pick, where you place your middleware decides what it can see. Middleware runs in the order you add it in Program.cs: top to bottom on the way in, and reverse on the way back.
Imagine you want your custom middleware to read the logged-in user. The user only exists after UseAuthentication has run. So your middleware must come after it. Put it before, and the user will always be empty.
app.UseRouting();
app.UseAuthentication(); // user is known after this line
app.UseRequestTiming(); // now your MW can read the user
app.UseAuthorization();
app.MapControllers();
app.Run();A few placement tips that save hours of confusion:
- Put error-handling middleware very early, so it can catch errors from everything below it.
- Put middleware that reads the user after
UseAuthentication. - Put middleware that should run for matched routes after
UseRouting. - If your middleware should stop some requests, decide carefully whether to call
nextor not.
A complete, working example
Let us tie it together with a small, complete Program.cs that uses all three styles at once. This is the kind of file you might really see in a beginner project.
var builder = WebApplication.CreateBuilder(args);
// Way 3 needs DI registration
builder.Services.AddScoped<IRequestStore, RequestStore>();
builder.Services.AddScoped<CorrelationIdMiddleware>();
var app = builder.Build();
// Way 1: inline, a quick health note
app.Use(async (context, next) =>
{
context.Response.Headers["X-Powered-By"] = "Learning .NET";
await next(context);
});
// Way 3: factory-based, gives each request a correlation id
app.UseMiddleware<CorrelationIdMiddleware>();
// Way 2: convention-based, times the request
app.UseRequestTiming();
app.MapGet("/", () => "All three middlewares ran!");
app.Run();When a request hits /, it passes through the inline header middleware, then the correlation id middleware, then the timing middleware, then the endpoint. On the way back, the timing middleware finishes last (because it started first), writing the elapsed time. That reverse order is the airport coming-home line again.
Common mistakes to avoid
Beginners hit the same small traps. Here are the ones to watch for:
- Forgetting
next. If you never callnext, the request never reaches the endpoint. Only skip it on purpose. - Writing to the response after it has started. Once the response body begins sending, you cannot change status codes or headers. Do that work before
await next. - Injecting scoped services into a convention-based constructor. Use
InvokeAsyncparameters, or switch toIMiddleware. - Forgetting to register
IMiddlewarein DI. The app will fail at startup with a clear message. - Wrong order. Place your middleware where it can actually see what it needs (user, route, etc.).
When should you write your own?
You do not need custom middleware for everything. ASP.NET Core already ships middleware for routing, authentication, authorization, CORS, HTTPS redirection, and more. Reach for those first.
Write your own when the job runs for every request and is cross-cutting, meaning it does not belong to one endpoint. Good examples are adding a correlation id, measuring timing, logging request details, or adding a custom security header. If the logic only matters for one endpoint, a filter or the endpoint code itself is usually a better home.
Quick recap
- Custom middleware is your own helper in the request pipeline, like an extra check at the airport.
- Every middleware has a before part, a call to next, and an after part.
- Way 1 is inline
app.Usewith a lambda, perfect for tiny jobs. - Way 2 is a convention-based class with
InvokeAsync, built once at startup (singleton-like). - Way 3 is a factory-based class that implements
IMiddleware, built fresh per request, safe for scoped services, but must be registered in DI. - Convention-based classes can still use scoped services by taking them as
InvokeAsyncparameters. - Order matters. Place middleware where it can see the user, route, or errors it needs.
- Always remember to call
next, and do response changes before the response starts.
References and further reading
- Write custom ASP.NET Core middleware — Microsoft Learn
- Factory-based middleware activation in ASP.NET Core — Microsoft Learn
- ASP.NET Core Middleware overview — Microsoft Learn
- 3 Ways To Create Middleware In ASP.NET Core — Milan Jovanović
- 3 Methods to Create Middleware in ASP.NET Core — Oleg Kyrylchuk
Related Posts
Getting Started With Middlewares in ASP.NET Core (Beginner Guide)
A friendly beginner guide to middleware in ASP.NET Core: how the request pipeline works, Use vs Run vs Map, order, and writing your own middleware.
3 Ways To Create Middleware In ASP.NET Core (Beginner Guide)
Learn the 3 ways to create middleware in ASP.NET Core: inline request delegates, convention-based classes, and factory-based IMiddleware, with simple diagrams and code.
Global Error Handling in ASP.NET Core: From Middleware to Modern Handlers
Learn global error handling in ASP.NET Core step by step: from try-catch middleware to IExceptionHandler and Problem Details, with simple diagrams and clear code.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.
HTTPS Redirection and HSTS in ASP.NET Core: A Simple Guide
Learn how to configure HTTPS redirection and HSTS in ASP.NET Core with simple examples, diagrams, and clear advice for development and production.