ASP.NET Web API (.NET Framework) · Cross-Cutting

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.

Advanced 6 min Lesson 27 of 36Covers ASP.NET Web API 2.2 on .NET Framework 4.8.
Before you start:Routing
0/36 in ASP.NET Web API
On this page · 6 min · Advanced

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#

C# Illustrative
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#

C#Handlers.cs Illustrative
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 });
    }
}
Expected output
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: 4

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. TimingHandler starts a stopwatch on the way in and adds a header on the way out.
  2. ApiKeyHandler checks the header; without the right key it returns 401 immediately.
  3. TimingHandler is global, so even the rejected request gets a timing header.
  4. The Partners route has its own pipeline: dispatcher + ApiKeyHandler, so only /partners/... needs the key.
  5. 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#

Forgetting to call base.SendAsync

Every request then stops in the handler and no controller ever runs.

Reading the body in a handler

Reading request.Content consumes the stream unless you buffer it (LoadIntoBufferAsync). Usually inspect headers only.

Timing-unsafe key comparison

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?
TryGetValues for the header; Guid.NewGuid().ToString() if missing; response.Headers.Add after base.SendAsync.
Show solution
C# Illustrative
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#

Message Handlers quizMultiple choice Question 1 of 3

When does a message handler run compared with routing?

Score so far: 0/0

Want more? Practise all ASP.NET Web API MCQs · ASP.NET Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.