Message Handlers
Message handlers sit in front of the whole Web API pipeline. Every request passes through them before routing, and every response passes back through them — the perfect place for cross-cutting checks.
On this page · 6 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Write a DelegatingHandler
- Short-circuit a request (e.g. missing API key)
- Modify responses (e.g. add headers)
- Register handlers globally or for one route
Explanation#
A DelegatingHandler overrides SendAsync(request, cancellationToken). Code before await base.SendAsync(...) runs on the way in; code after it runs on the way out.
Returning a response without calling base.SendAsync short-circuits the pipeline: the controller never runs. Ideal for API-key checks or maintenance mode.
Handlers run in the order they are added to config.MessageHandlers for requests, and in reverse order for responses — like layers of an onion.
Handlers run before routing and model binding, so they see raw HTTP only. For checks that need the selected controller or action, use filters instead.
A handler can be attached to a single route by building a pipeline with HttpClientFactory.CreatePipeline — useful for partner-only endpoints.
ASP.NET Core replaced message handlers with middleware, which follows the same idea.
Syntax#
public class MyHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// before the controller
var response = await base.SendAsync(request, cancellationToken);
// after the controller
return response;
}
}
Code example: API-key and timing handlers#
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Http;
using System.Web.Http.Dispatcher;
public class TimingHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var timer = Stopwatch.StartNew();
var response = await base.SendAsync(request, cancellationToken);
response.Headers.Add("X-Response-Time-ms", timer.ElapsedMilliseconds.ToString());
return response;
}
}
public class ApiKeyHandler : DelegatingHandler
{
private const string HeaderName = "X-Api-Key";
private readonly string _expectedKey;
public ApiKeyHandler(string expectedKey)
{
_expectedKey = expectedKey;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
IEnumerable<string> values;
if (!request.Headers.TryGetValues(HeaderName, out values) || values.FirstOrDefault() != _expectedKey)
{
// Short-circuit: the controller never runs
return request.CreateErrorResponse(HttpStatusCode.Unauthorized, "Missing or invalid API key.");
}
return await base.SendAsync(request, cancellationToken);
}
}
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
config.MapHttpAttributeRoutes();
// Global: every request is timed
config.MessageHandlers.Add(new TimingHandler());
// Per-route: only /partners/... requires the partner API key
config.Routes.MapHttpRoute(
name: "Partners",
routeTemplate: "partners/{controller}/{id}",
defaults: new { id = RouteParameter.Optional },
constraints: null,
handler: HttpClientFactory.CreatePipeline(
new HttpControllerDispatcher(config),
new DelegatingHandler[] { new ApiKeyHandler(ConfigurationManager.AppSettings["PartnerApiKey"]) }));
config.Routes.MapHttpRoute("DefaultApi", "api/{controller}/{id}", new { id = RouteParameter.Optional });
}
}
public class StockController : ApiController
{
public IHttpActionResult Get()
{
return Ok(new { pens = 40, notebooks = 120 });
}
}
GET /api/stock → 200 {"pens":40,"notebooks":120} X-Response-Time-ms: 3
GET /partners/stock → 401 {"Message":"Missing or invalid API key."} X-Response-Time-ms: 0
GET /partners/stock X-Api-Key: <correct> → 200 {"pens":40,"notebooks":120} X-Response-Time-ms: 4This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- TimingHandler starts a stopwatch on the way in and adds a header on the way out.
- ApiKeyHandler checks the header; without the right key it returns 401 immediately.
- TimingHandler is global, so even the rejected request gets a timing header.
- The Partners route has its own pipeline: dispatcher + ApiKeyHandler, so only /partners/... needs the key.
- The same StockController serves both routes; the handler decides who gets in.
Real-world use case#
A logistics company gives each courier partner an API key. A message handler validates the key, looks up the partner, adds a PartnerId to request.Properties, and rejects suspended partners before any controller code runs.
Common mistakes#
Every request then stops in the handler and no controller ever runs.
Reading request.Content consumes the stream unless you buffer it (LoadIntoBufferAsync). Usually inspect headers only.
For high-value secrets, compare keys with a constant-time comparison to avoid timing attacks, and store them outside source control.
Best practices#
- Use handlers for raw-HTTP concerns (keys, headers, correlation IDs)
- Use filters for action-aware concerns
- Keep handlers fast and async
- Register per-route handlers for partner or internal areas
Remember this#
Before await base.SendAsync = request in. After = response out.
Return early = short-circuit.
Handlers run before routing; filters run around actions.
Practice exercise#
Your task: Write a CorrelationIdHandler that reads an X-Correlation-Id request header (or creates a new GUID), stores it in request.Properties["CorrelationId"], and adds it to the response headers.
Need a hint?
Show solution
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
public class CorrelationIdHandler : DelegatingHandler
{
private const string Header = "X-Correlation-Id";
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
IEnumerable<string> values;
string id = request.Headers.TryGetValues(Header, out values)
? values.First()
: Guid.NewGuid().ToString();
request.Properties["CorrelationId"] = id;
var response = await base.SendAsync(request, cancellationToken);
response.Headers.Add(Header, id);
return response;
}
}
Mini quiz#
When does a message handler run compared with routing?
Want more? Practise all ASP.NET Web API MCQs · ASP.NET Web API interview questions
My notes#
Saved in this browser.