Delegating Handlers
Write DelegatingHandlers that add cached access tokens and correlation ids to every outbound call, in the right order in the chain.
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<ShippingClient>(...)
.AddHttpMessageHandler<BearerTokenHandler>();A DelegatingHandler is outbound middleware. It overrides SendAsync, can change the request, calls base.SendAsync to pass it to InnerHandler, and can inspect the response on the way back. Cross-cutting concerns live here so typed clients stay focused on URLs and DTOs: authentication headers, correlation ids, API keys, custom logging, request signing.
Registering and ordering
Register the handler as transient and attach it with AddHttpMessageHandler<T>(). The factory builds a new instance for each handler chain it creates, so a handler must not be a singleton or be reused across clients (an InvalidOperationException tells you the inner handler was already set). Handlers run in registration order: the first one added is the outermost. That matters next to a resilience handler.
| Registered | Effect |
|---|---|
| Auth handler, then resilience | Token is attached once; every retry reuses it, even one sent after it expired |
| Resilience, then auth handler | The handler runs on every attempt, so each retry gets a current token |
| Correlation id handler outermost | One id for the whole logical call, including its retries |
Because a handler can see the same HttpRequestMessage more than once on retries, set headers by assignment (request.Headers.Authorization = ...) or check Contains first; Headers.Add on a retry sends the header twice.
Lifetime and DI scope
A handler lives as long as its chain (the 2-minute handler lifetime) and is resolved from a scope the factory creates for that chain, not from the scope of the HTTP request being served. Constructor-injected scoped services are therefore not the request's instances. Keep handlers stateless and depend on singletons. To read something about the incoming request, such as the caller's bearer token to forward, use IHttpContextAccessor inside SendAsync, which reads the current request through an async local.
Token caching
For service-to-service calls with OAuth client credentials, fetching a token per request adds a round trip to the identity provider and gets you rate-limited by it. Put a singleton token provider behind the handler: cache the token until shortly before expires_in (a 60-second margin covers clock skew and in-flight time), and guard the refresh with a SemaphoreSlim so 200 concurrent requests trigger one token request, not 200. Fetch the token with its own named client that does not go through the auth handler, or the token request recurses into itself.
Correlation and tracing
HttpClient already propagates W3C traceparent headers from Activity.Current, so with OpenTelemetry you do not need a handler for trace ids. A correlation handler is still useful for a partner that expects its own header, such as X-Correlation-ID; reuse the current trace id so logs on both sides join up.
Production failure modes
- Logging handlers that write
Authorizationheaders or request bodies with card data into logs. - A handler that stores the last request in a field: chains are shared by concurrent calls.
- A token cache keyed by nothing in a multi-tenant service, sending tenant A's token for tenant B.
Example
When to use it
- Attach a client-credentials token to every call to the shipping API while requesting a new token only once every five minutes.
- Forward the signed-in customer's bearer token from the storefront API to the customer profile service.
- Send a partner's required X-Correlation-ID header built from the current trace id so both sides' logs join up.
More examples
Forwarding the caller's token to a downstream API
Forwarding the user's token keeps the downstream service's authorisation decisions about the real user. The token is read inside <code>SendAsync</code> through <code>IHttpContextAccessor</code>, never in the constructor, because one handler instance serves many requests. The endpoint requires an authenticated caller and only a <code>Bearer</code> token is forwarded, so the API is not an open pass-through proxy. The profile service must accept a token whose audience is the storefront API; if it validates its own audience strictly, use a token exchange or on-behalf-of flow to get a token issued for it instead. It only works while a request is in flight; from a background job <code>HttpContext</code> is null.
using System.Net.Http.Headers;
using Microsoft.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<ForwardUserTokenHandler>();
builder.Services.AddHttpClient("customer-profile", http => http.BaseAddress = new Uri("https://profiles.shop.internal/"))
.AddHttpMessageHandler<ForwardUserTokenHandler>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/me/addresses", async (IHttpClientFactory factory, CancellationToken ct) =>
Results.Ok(await factory.CreateClient("customer-profile").GetFromJsonAsync<Address[]>("me/addresses", ct)))
.RequireAuthorization();
app.Run();
public sealed class ForwardUserTokenHandler(IHttpContextAccessor accessor) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
// Read per call: the handler outlives any single request.
var header = accessor.HttpContext?.Request.Headers[HeaderNames.Authorization].ToString();
// Bearer only: never pass on Basic credentials or any other scheme.
if (!string.IsNullOrEmpty(header) && AuthenticationHeaderValue.TryParse(header, out var value)
&& string.Equals(value.Scheme, "Bearer", StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = value;
}
return base.SendAsync(request, ct);
}
}
public sealed record Address(string Line1, string City, string PostalCode, string Country);
Refreshing once on 401
A token can be revoked or the signing key rotated before the cached expiry. One forced refresh on 401 recovers from that without a restart; a loop would hammer the identity provider when the real problem is a missing permission. Disposing the first response returns its connection to the pool before the second attempt.
using System.Net;
using System.Net.Http.Headers;
public interface IServiceTokenCache
{
ValueTask<string> GetAsync(CancellationToken ct);
void Invalidate();
}
public sealed class RefreshingBearerHandler(IServiceTokenCache tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await tokens.GetAsync(ct));
var response = await base.SendAsync(request, ct);
// Only bodiless requests are resent: a consumed content stream cannot be replayed.
if (response.StatusCode != HttpStatusCode.Unauthorized || request.Content is not null)
{
return response;
}
response.Dispose();
tokens.Invalidate();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await tokens.GetAsync(ct));
return await base.SendAsync(request, ct);
}
}
Unit testing a handler without a server
<code>HttpMessageInvoker</code> runs a handler chain directly, without <code>HttpClient</code>'s rule that a request message can be sent only once, so the test can replay the same request the way a retry handler does. Setting <code>InnerHandler</code> to a capturing stub makes the handler testable in isolation, with no network and no DI.
using System.Net;
using Xunit;
public class CorrelationHandlerTests
{
[Fact]
public async Task Adds_a_correlation_id_once()
{
var terminal = new CaptureHandler();
var handler = new AddCorrelationIdHandler { InnerHandler = terminal };
using var invoker = new HttpMessageInvoker(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, "https://shipping.example/quotes/1001");
await invoker.SendAsync(request, TestContext.Current.CancellationToken);
await invoker.SendAsync(request, TestContext.Current.CancellationToken); // simulates a retry
Assert.Single(terminal.LastRequest!.Headers.GetValues("X-Correlation-ID"));
}
private sealed class CaptureHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
}
public sealed class AddCorrelationIdHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
if (!request.Headers.Contains("X-Correlation-ID"))
{
request.Headers.Add("X-Correlation-ID", Guid.NewGuid().ToString("N"));
}
return base.SendAsync(request, ct);
}
}
Discussion