Skip to content
KB

How do I attach a bearer token to every HttpClient request with a DelegatingHandler?

howto · dotnet · csharp · httpclient · authentication

Override `SendAsync` in a `DelegatingHandler` subclass to set `request.Headers.Authorization`, register the handler `Transient`, and chain `.AddHttpMessageHandler<TheHandler>()` onto the client's `AddHttpClient(...)` registration - never set the header on the client itself.

A DelegatingHandler sits in the pipeline IHttpClientFactory builds for a client, between the code that calls HttpClient.SendAsync and the socket-level handler that actually makes the request, so it sees - and can rewrite - every request before it goes out:

examples/dotnet/delegatinghandler-bearer-token.cs
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}

compiled and run by the build

Show the whole file
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

Register the handler itself, then chain it onto the client:

examples/dotnet/delegatinghandler-bearer-token.cs
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();

compiled and run by the build

Show the whole file
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

AddHttpMessageHandler<BearerTokenHandler>() resolves BearerTokenHandler from DI every time the factory builds a new handler pipeline for this client and wires it into the chain; nothing else in WidgetApiClient or its callers changes; every request the typed client sends now carries the header. The same call works on a named client (AddHttpClient("widgets", ...), resolved later with IHttpClientFactory.CreateClient("widgets")) exactly the same way - the handler doesn’t care which consumption pattern registered the client it ends up attached to.

Why the handler has to be registered Transient

Section titled “Why the handler has to be registered Transient”

IHttpClientFactory doesn’t build the pipeline once: by default it recycles the underlying HttpMessageHandler chain every two minutes (SetHandlerLifetime on the IHttpClientBuilder changes that), so that DNS changes eventually reach a client that never restarts. Each recycle asks DI for a fresh instance of every type named in an AddHttpMessageHandler<T>() call and wires it to the new inner handler. A handler registered Singleton would be handed back the same instance each time, still wired to the first pipeline’s inner handler, and the factory refuses it: building the second pipeline throws an InvalidOperationException saying that a DelegatingHandler “must not be reused or cached”, so the app works for the first handler lifetime and fails the next time a client is created after it. And if the handler’s own constructor takes a scoped service (an ITokenProvider backed by a DbContext, say), a Singleton registration captures that scoped dependency outside any request scope and keeps it alive, and stale, for the app’s entire life. AddTransient is what keeps a fresh handler matched to each pipeline the factory builds - but that is the only thing it fixes. It does not make the handler’s own dependencies request-scoped, because IHttpClientFactory never resolves them from the request’s scope, or from any scope the caller is in, to begin with; the next section covers what actually happens instead.

Transient doesn’t make the handler’s dependencies request-scoped

Section titled “Transient doesn’t make the handler’s dependencies request-scoped”

IHttpClientFactory builds each pipeline inside its own dependency-injection scope - the docs call it a message handler scope - which is separate from the app’s own scopes: an ASP.NET Core request scope, or one an app creates by hand with IServiceScopeFactory.CreateScope(). AddTransient<BearerTokenHandler>() only says that a fresh instance of the handler is resolved every time the factory (re)builds a pipeline; it says nothing about whose scope resolves it, because the factory always resolves it from its own message handler scope, never from whatever scope happened to be current when a caller asked for the client. That scope lives exactly as long as the handler does - the same lifetime SetHandlerLifetime controls, two minutes by default - not one request, so a handler built once at the start of that window keeps answering every request that arrives during it, and so does whatever scoped service its constructor captured at that moment: an ITokenProvider wrapping a scoped DbContext gets one fresh DbContext per pipeline rebuild, not one per request, and every request in between shares it. This is exactly the “reusing the same HttpMessageHandler instance with the same injected scoped dependencies between several incoming requests” the docs warn about, and nothing catches it the way the factory catches a Singleton handler: the app builds, the handler is transient, and the bug is a scoped dependency quietly shared between whichever requests land inside one handler lifetime. Two requests, resolved from two different IServiceScopes a moment apart, still run through a handler carrying the same captured scoped instance as long as neither request is the one that happens to rebuild the pipeline:

sequenceDiagram
    participant Factory as Factory
    participant H as Handler, one instance
    participant R1 as Request 1, scope A
    participant R2 as Request 2, scope B
    Factory->>H: build pipeline, resolve scoped dep from its own scope
    R1->>H: send
    H-->>R1: uses the dep captured at build time
    R2->>H: send
    H-->>R2: uses that same dep, not scope B's own

Reaching request-scoped state from inside a handler

Section titled “Reaching request-scoped state from inside a handler”

Keep the handler itself out of this by not injecting scoped state into it at all. In ASP.NET Core the usual fix is IHttpContextAccessor: register it once (AddHttpContextAccessor()), inject it into the handler instead of the scoped service, and read HttpContext.RequestServices for whatever scoped dependency is actually needed. IHttpContextAccessor is itself a Singleton, backed by an AsyncLocal<HttpContext> the framework sets at the start of every request, so it always resolves to the request that is currently running, regardless of which scope the object holding it came from - the one problem the message handler scope has no way around on its own.

When the state has to come from a scoped service that isn’t reachable through HttpContext at all, the docs’ own workaround is to keep that logic out of AddHttpMessageHandler<T>() entirely: resolve a transient DelegatingHandler directly from the request’s own IServiceProvider, so it gets that request’s scoped dependencies, and wrap it around the pooled handler IHttpMessageHandlerFactory.CreateHandler(name) returns for the named or typed client, building the HttpClient by hand instead of through CreateClient:

examples/dotnet/delegatinghandler-bearer-token.cs
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}

compiled and run by the build

Show the whole file
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

A handler built this way is wired around the factory’s own pooled handler for the "widgets" client rather than through a second AddHttpMessageHandler<T>() call, so it answers to whichever request’s IServiceProvider built it while the pooled inner handler underneath - the actual socket-level connection pool IHttpClientFactory exists to manage - is still shared and still recycled on the same timer as every other client.

Handlers run in the order they’re added: the first AddHttpMessageHandler call becomes the outermost link in the chain, so it sees the request first on the way out and the response last on the way back.

flowchart TD
    C[Caller] --> H[HttpClient]
    H --> H1[First handler added]
    H1 --> H2[Handlers added after it]
    H2 --> B[BearerTokenHandler sets Authorization]
    B --> P[Primary handler]
    P --> S[Server]
    S -.-> P
    P -.-> B
    B -.-> H2
    H2 -.-> H1
    H1 -.-> H
    H -.-> C
examples/dotnet/delegatinghandler-bearer-token.cs
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler

compiled and run by the build

Show the whole file
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

Put the bearer-token handler last (closest to the socket handler) when a handler ahead of it - a logging or retry handler - should see the request without the header already on it, or first when a retry handler needs the header present so a retried request is authorized the same way the original was; either order is legitimate; the two orders are simply two different questions about what an earlier handler in the chain gets to see.

Variant: a token that has to be looked up dynamically

Section titled “Variant: a token that has to be looked up dynamically”

config["ApiToken"] above stands for the simplest case, a token fixed for the app’s whole life - a machine-to-machine client secret pulled from configuration once. A token that expires and has to be refreshed (an interactive sign-in, an OAuth client-credentials grant renewed on a timer) puts the lookup and refresh behind its own interface the handler calls instead, and retries once on a 401; dotnet/jwt-authentication-desktop-client covers that handler and the ITokenProvider behind it in full, including the MSAL- and OIDC-client-backed implementations.

The provider owns the refresh in every case, not the handler, whatever kind of client is asking: the handler’s job stays “attach the current token, retry once on a 401”, and everything about how a token is obtained - an interactive browser sign-in, a client-credentials grant, a cached secret - lives behind ITokenProvider and never leaks into the handler. Putting refresh logic in the handler instead means rewriting the handler for every new auth mechanism the app grows into, and it makes the handler untestable without a real identity provider to talk to. This holds for a desktop app exactly as it does for an ASP.NET Core service, a worker service or a console job: what changes between them is only what the provider does internally, never which side calls which.

Guard the refresh with a lock, on every kind of client

Section titled “Guard the refresh with a lock, on every kind of client”

A desktop app is not one UI-bound sequence of requests: a view that loads several panels at startup, or refreshes a few independent lists, fires them concurrently with Task.WhenAll the same way a busy service handles several inbound requests at once, and every one of those calls can get a 401 within milliseconds of the others - nothing about running on a desktop serializes them. What actually differs between a library-backed provider and a hand-rolled one is not the kind of client, but who makes the network call. MsalTokenProvider.RefreshAccessTokenAsync in dotnet/jwt-authentication-desktop-client only ever calls AcquireTokenSilent(...).WithForceRefresh(true): MSAL redeems the refresh token internally, behind its own cache, and application code never gets an unguarded round trip to the token endpoint of its own to race. OidcTokenProvider in the same entry, and the client_credentials provider below, are the opposite: application code itself posts to the token endpoint, so nothing stops two concurrent callers from doing that at once unless the provider stops them itself. For a provider that trades a refresh token for a new access token - OidcTokenProvider - that race can be actively harmful: an identity provider that rotates refresh tokens invalidates the previous one the moment a new one is issued, so whichever request loses the race redeems a refresh token that is already dead and fails outright, on a desktop client exactly as on a service. A client_credentials provider has no refresh token to lose - it just asks the token endpoint for a new access token each time - so the unguarded race is milder but still wasteful: every caller past expiry hits the token endpoint at once instead of the one caller reusing what’s already in flight, which burns round trips at best and trips a per-client rate limit on the token endpoint at worst. Either way, any provider that performs the token-endpoint call itself needs the lock, whatever kind of client it runs in: guard the refresh with one, and let every caller that loses the race wait for, and reuse, the refresh already in flight, the way a client-credentials provider for a service-to-service client would:

flowchart TD
    A401[A's request: 401] --> AAsk[A asks the provider to refresh]
    B401[B's request: 401] --> BAsk[B asks the provider to refresh]
    AAsk --> Lock{Lock free?}
    BAsk --> Lock
    Lock -- A gets it --> ACall[A calls the token endpoint]
    Lock -- B waits --> BWait[B waits for the lock]
    ACall --> ACache[A caches the new token]
    ACache --> AFree[A releases the lock]
    AFree --> ARetry[A retries once]
    AFree --> BTurn[B takes the lock]
    BWait --> BTurn
    BTurn --> BCheck{Already refreshed?}
    BCheck -- yes --> BReuse[B reuses the new token]
    BReuse --> BRetry[B retries once]
examples/dotnet/delegatinghandler-bearer-token.cs
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);

compiled and run by the build

Show the whole file
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

The [property: JsonPropertyName(...)] attributes matter because the token endpoint’s response uses the snake_case field names RFC 6749 specifies (access_token, expires_in); ReadFromJsonAsync<T>()’s case-insensitive default matching still doesn’t bridge an underscore the record’s AccessToken/ExpiresIn property names don’t have, so without the attributes both silently deserialize to their defaults, null and 0, and the cache.Set then throws an ArgumentOutOfRangeException for the negative expiration: every refresh fails and nothing is cached. The -30 seconds of slack keeps the cache from handing out a token that expires mid-flight to the API; the check inside the lock is what stops every caller that had to wait from paying for a second network round trip once the first one already refreshed. It asks whether a refresh finished while the caller waited, not whether a token is cached: after a 401, the cached token is the one the API just refused, and handing it back would fail the retry too. This provider registers the same AddTransient + AddHttpMessageHandler<BearerTokenHandler>() way as the desktop one; only IMemoryCache (AddMemoryCache()) needs registering alongside it.

A library instead of the hand-rolled provider

Section titled “A library instead of the hand-rolled provider”

Two documented alternatives do the same job as ClientCredentialsTokenProvider above without writing the cache, the lock or the field-name attributes by hand:

  • Duende.AccessTokenManagement (Apache-2.0, the Duende.AccessTokenManagement package) wires a client_credentials client straight into IHttpClientFactory: AddClientCredentialsTokenManagement() plus one AddClient(...) call registers the token endpoint, its scopes and its credentials, and AddClientCredentialsHttpClient(name, clientName, ...) - or, on a typed client, AddHttpClient<T>().AddClientCredentialsTokenHandler() - returns an HttpClient that requests, caches and renews its own token with no DelegatingHandler or ITokenProvider of its own; its handler replaces BearerTokenHandler for that client rather than plugging into it.
  • MSAL’s AcquireTokenForClient, on a ConfidentialClientApplication, is the client_credentials counterpart to the AcquireTokenSilent call dotnet/jwt-authentication-desktop-client uses for a public client: it keeps its own application token cache, so a call before expiry never reaches the network, and a concurrent call is a race MSAL’s own cache resolves, not something the app has to guard. It reaches only Microsoft Entra ID (or another Microsoft-identity-platform authority), so it earns its keep over Duende.AccessTokenManagement mainly when Entra ID is already the identity provider elsewhere in the app and a second library just for this one client isn’t worth adding.

ClientCredentialsTokenProvider still earns its place against either one for an identity provider neither talks to out of the box, and for keeping every provider - this one, an MSAL desktop sign-in, an OidcClient refresh - behind the same ITokenProvider interface BearerTokenHandler calls, so the handler above never has to change no matter which one is wired up. Both libraries’ own handlers bypass ITokenProvider and BearerTokenHandler together, which is the right trade only when nothing else in the app is expected to share that interface.

The trap: setting the header on the client instead of in a handler

Section titled “The trap: setting the header on the client instead of in a handler”

AddHttpClient’s configuration delegate looks like the obvious place to add the header, and it compiles:

examples/dotnet/delegatinghandler-bearer-token.cs
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));

compiled and run by the build

Show the whole file
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

The delegate runs once for each HttpClient the factory creates - every CreateClient call, every resolution of a typed client - and never again for that client; it does not run per request. So a client that lives on keeps the token of the moment it was made: a typed client injected into a singleton service, or into a view model that lives as long as its window, is created once and sends that first token until the app exits, however often the token has been renewed since. The example holds exactly that: two requests through one client run the delegate once, a token renewed afterwards never reaches that client, and only a client created later carries the new one. Fetching the token synchronously (.Result, .GetAwaiter().GetResult()) inside that delegate, because it isn’t async, adds the classic sync-over-async deadlock in a host that has a synchronization context, a WPF or WinForms UI thread. A DelegatingHandler’s SendAsync runs once per request and is already async, so it always attaches the token current at send time without blocking anything.

A second, smaller trap: forgetting AddTransient<BearerTokenHandler>() and only calling .AddHttpMessageHandler<BearerTokenHandler>() builds and starts. It fails when the client is first created - for a typed client, the first time it is resolved - with an InvalidOperationException naming the unregistered type; the two calls both have to be there.

Unit-test the handler alone, without a real network call, by giving it a stub inner handler that just records the request it received (Assert is xUnit’s):

examples/dotnet/delegatinghandler-bearer-token.cs
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);

compiled and run by the build

Show the whole file
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

For the wired-up app, point the client at a request-echoing endpoint (a local one, or https://httpbin.org/bearer for a quick manual check) and confirm the Authorization header it reports matches the token, or capture traffic with a local proxy (Fiddler, mitmproxy) while the app runs; either way, renew the token while the app keeps running and send again through the same client: the header has to carry the new token, which is what catches a token that was baked into the client instead of attached per request (the trap above). A restart does not: the client created after it reads the current token, so the header matches whether or not the trap is there.

A script or a single one-off call that builds its own HttpClient and never goes through DI has nothing to gain from a handler and a DI registration - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token) set once, right after constructing that HttpClient, is the whole answer, because there is no pipeline recycling and no second call to keep in sync with the first. The handler pattern earns its keep once IHttpClientFactory is already in the picture - more than one call, a typed or named client, or a token that can change while the app keeps running.

Example files

All the files of examples/dotnet/delegatinghandler-bearer-token.cs, where the code above comes from, and the shared code it compiles in: examples/dotnet/shared/WidgetApi/.

shared/WidgetApi/WidgetApi.csproj
examples/dotnet/shared/WidgetApi/WidgetApi.csproj
<Project Sdk="Microsoft.NET.Sdk">
<!-- The API client the bearer-token examples register with AddHttpClient,
written once and referenced by each (#:project in a file-based app).
The gate compiles it on its own and with every example that
references it. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

compiled and run by the build

shared/WidgetApi/WidgetApiClient.cs
examples/dotnet/shared/WidgetApi/WidgetApiClient.cs
namespace WidgetApi;
// A typed client for an API that wants a bearer token, which knows nothing
// about tokens: a request it makes carries one only when a handler in its
// HttpClient's pipeline adds it.
public interface IWidgetApiClient
{
Task GetWidgetsAsync();
}
public sealed class WidgetApiClient(HttpClient http) : IWidgetApiClient
{
public async Task GetWidgetsAsync() => (await http.GetAsync("widgets")).EnsureSuccessStatusCode();
}

compiled and run by the build

delegatinghandler-bearer-token.cs
examples/dotnet/delegatinghandler-bearer-token.cs
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package Microsoft.Extensions.Caching.Memory@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// Attaching a bearer token to every request with a DelegatingHandler. The
// marked regions are shown on the page; the rest runs them against stub
// handlers, so no request leaves the machine, and records that every claim
// of the entry still holds. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WidgetApi;
using Xunit;
await Claims.HandlerAlone();
await Claims.Registration(args);
await Claims.ChainOrder(args);
await Claims.HeaderOnTheClientGoesStale();
Claims.UnregisteredHandlerFailsWhenTheClientIsCreated();
Claims.SingletonHandlerIsRefusedByTheSecondPipeline();
await Claims.MessageHandlerScopeIsNotTheCallersScope();
await Claims.ScopeAwareHandlerReachesEachRequestsOwnState();
await Claims.ConcurrentRefreshHitsTheTokenEndpointOnce();
Claims.SnakeCaseNeedsTheAttributes();
Console.WriteLine("delegatinghandler-bearer-token: all claims hold");
static class Claims
{
static readonly Dictionary<string, string?> Settings = new()
{
["ApiToken"] = "token-1",
["ApiBaseUrl"] = "https://api.example.invalid/",
};
public static async Task HandlerAlone()
{
IConfiguration config = new ConfigurationBuilder().AddInMemoryCollection(Settings).Build();
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OK
var handler = new BearerTokenHandler(config) { InnerHandler = stub };
using var client = new HttpClient(handler);
await client.GetAsync("https://example.com/widgets");
Assert.Equal("Bearer", stub.LastRequest!.Headers.Authorization!.Scheme);
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter);
}
public static async Task Registration(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
var stub = new StubHandler();
using var host = Build(builder, stub);
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
// Nothing in WidgetApiClient mentions a token, and the request carries one.
Assert.Equal("Bearer token-1", stub.LastRequest!.Headers.Authorization!.ToString());
Assert.Equal(new Uri("https://api.example.invalid/widgets"), stub.LastRequest.RequestUri);
}
public static async Task ChainOrder(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<LoggingHandler>() // logs the request, then the eventual response
.AddHttpMessageHandler<BearerTokenHandler>(); // attaches the token just before the socket handler
LoggingHandler.Seen.Clear();
using (var host = Build(builder, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Added first, so outermost: it saw the request before the token went on.
Assert.Equal(["request without Authorization", "response 200"], LoggingHandler.Seen);
// The other order is the other question: the logger now sees the header.
var reversed = Host.CreateApplicationBuilder(args);
reversed.Services.AddTransient<BearerTokenHandler>();
reversed.Services.AddTransient<LoggingHandler>();
reversed.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(reversed.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>();
LoggingHandler.Seen.Clear();
using (var host = Build(reversed, new StubHandler()))
{
await host.Services.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
Assert.Equal(["request with Authorization", "response 200"], LoggingHandler.Seen);
}
// The trap: the delegate given to AddHttpClient runs once for each
// HttpClient the factory creates and never again for that client, so a
// header set there is the token of the moment the client was made.
public static async Task HeaderOnTheClientGoesStale()
{
var tokens = new TokenSource("token-1"); // counts how often the token is asked for
var stub = new StubHandler();
var services = new ServiceCollection();
services.AddHttpClient("widgets", client =>
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
var longLived = factory.CreateClient("widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(1, tokens.Reads); // two requests, one run of the delegate
tokens.Renew("token-2"); // the token is renewed while the client lives on
await longLived.GetAsync("https://api.example.invalid/widgets");
Assert.Equal("token-1", stub.LastRequest!.Headers.Authorization!.Parameter); // stale
// Only a client created afterwards gets the new one.
await factory.CreateClient("widgets").GetAsync("https://api.example.invalid/widgets");
Assert.Equal(2, tokens.Reads);
Assert.Equal("token-2", stub.LastRequest!.Headers.Authorization!.Parameter);
}
// The smaller trap: AddHttpMessageHandler<T>() without AddTransient<T>()
// builds and starts; it throws when the client is first created.
public static void UnregisteredHandlerFailsWhenTheClientIsCreated()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>().AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var error = Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IWidgetApiClient>());
Assert.Contains(nameof(BearerTokenHandler), error.Message);
}
// Why Transient: every pipeline the factory builds needs a handler of its
// own. A Singleton is the same instance again, already wired to the first
// pipeline, and the factory refuses to wire it into a second one. Two
// clients stand in here for one client's pipeline being rebuilt when its
// handler lifetime (two minutes by default) runs out.
public static void SingletonHandlerIsRefusedByTheSecondPipeline()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(Settings).Build());
services.AddSingleton<BearerTokenHandler>();
services.AddHttpClient("first").AddHttpMessageHandler<BearerTokenHandler>();
services.AddHttpClient("second").AddHttpMessageHandler<BearerTokenHandler>();
using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
factory.CreateClient("first");
var error = Assert.Throws<InvalidOperationException>(() => factory.CreateClient("second"));
Assert.Contains("must not be reused or cached", error.Message);
}
// Transient only says a fresh handler is built per pipeline; it says
// nothing about whose DI scope resolves it. IHttpClientFactory resolves
// AddHttpMessageHandler<T>() from its own message handler scope, never
// from the scope that called CreateClient, so two requests running in
// two different app scopes still share whatever scoped dependency the
// handler captured when its pipeline was last built.
public static async Task MessageHandlerScopeIsNotTheCallersScope()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddTransient<ScopeAwareHandler>();
services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri("https://api.example.invalid/"))
.AddHttpMessageHandler<ScopeAwareHandler>();
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using (var requestOne = provider.CreateScope())
{
await requestOne.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
using (var requestTwo = provider.CreateScope())
{
await requestTwo.ServiceProvider.GetRequiredService<IWidgetApiClient>().GetWidgetsAsync();
}
// Two fresh IServiceScopes, each with its own RequestMarker - yet the
// handler that served both requests captured the same one.
Assert.Equal(2, ScopeAwareHandler.Seen.Count);
Assert.Equal(ScopeAwareHandler.Seen[0], ScopeAwareHandler.Seen[1]);
}
// The documented workaround: keep the scope-aware handler out of
// AddHttpMessageHandler<T>() entirely, resolve it from the request's own
// IServiceProvider so it gets that request's scoped dependencies, and
// wrap it around the pooled handler IHttpMessageHandlerFactory hands out
// for the named client.
public static async Task ScopeAwareHandlerReachesEachRequestsOwnState()
{
var services = new ServiceCollection();
services.AddScoped<RequestMarker>();
services.AddHttpClient("widgets", client => client.BaseAddress = new Uri("https://api.example.invalid/"));
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler()));
using var provider = services.BuildServiceProvider();
ScopeAwareHandler.Seen.Clear();
using var requestOne = provider.CreateScope();
var markerOne = requestOne.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestOne.ServiceProvider);
using var requestTwo = provider.CreateScope();
var markerTwo = requestTwo.ServiceProvider.GetRequiredService<RequestMarker>().Id;
await CallThroughARequestScopedClient(requestTwo.ServiceProvider);
Assert.Equal(markerOne, ScopeAwareHandler.Seen[0]);
Assert.Equal(markerTwo, ScopeAwareHandler.Seen[1]);
Assert.NotEqual(markerOne, markerTwo); // each request's handler saw only its own scope's marker
}
static async Task CallThroughARequestScopedClient(IServiceProvider requestScope)
{
var scopeAware = new ScopeAwareHandler(requestScope.GetRequiredService<RequestMarker>())
{
InnerHandler = requestScope.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler("widgets"),
};
using var client = new HttpClient(scopeAware, disposeHandler: false)
{
BaseAddress = new Uri("https://api.example.invalid/"),
};
await client.GetAsync("widgets");
}
public static async Task ConcurrentRefreshHitsTheTokenEndpointOnce()
{
var endpoint = new TokenEndpointStub();
using var tokenClient = new HttpClient(endpoint) { BaseAddress = new Uri("https://login.example.invalid") };
using var cache = new MemoryCache(new MemoryCacheOptions());
var tokens = new ClientCredentialsTokenProvider(tokenClient, cache);
cache.Set("access_token", "rejected-token", TimeSpan.FromMinutes(5)); // revoked, but still cached
// Eight requests get a 401 for it at once and all ask for a refresh.
var refreshed = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => tokens.RefreshAccessTokenAsync()));
Assert.All(refreshed, t => Assert.Equal("fresh-token", t)); // never the token the API refused
Assert.Equal(1, endpoint.Calls); // the seven that waited reused the first one's token
Assert.Equal("fresh-token", await tokens.GetAccessTokenAsync());
Assert.Equal(1, endpoint.Calls); // and a later read comes from the cache
}
// Without [JsonPropertyName], access_token finds no property: the default
// matching ignores case, not underscores, and the token is silently null.
// So is expires_in, 0, and the provider's cache.Set then throws.
public static void SnakeCaseNeedsTheAttributes()
{
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var bare = JsonSerializer.Deserialize<BareTokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Null(bare.AccessToken);
Assert.Equal(0, bare.ExpiresIn);
using var cache = new MemoryCache(new MemoryCacheOptions());
Assert.Throws<ArgumentOutOfRangeException>(() => cache.Set("access_token", bare.AccessToken, TimeSpan.FromSeconds(bare.ExpiresIn - 30)));
Assert.False(cache.TryGetValue("access_token", out _));
var named = JsonSerializer.Deserialize<TokenResponse>(TokenEndpointStub.Body, web)!;
Assert.Equal("fresh-token", named.AccessToken);
Assert.Equal(3600, named.ExpiresIn);
}
// The host as the entry registers it, with the settings the registration
// reads and a stub where the socket handler would be.
static IHost Build(HostApplicationBuilder builder, HttpMessageHandler primary)
{
builder.Configuration.AddInMemoryCollection(Settings);
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => primary));
return builder.Build();
}
}
public sealed class BearerTokenHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config["ApiToken"]);
return base.SendAsync(request, cancellationToken);
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
}
public sealed class ClientCredentialsTokenProvider(HttpClient tokenClient, IMemoryCache cache) : ITokenProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
private int _refreshes;
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
cache.TryGetValue<string>("access_token", out var token) ? Task.FromResult(token!) : RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var seen = Volatile.Read(ref _refreshes);
await _lock.WaitAsync(ct);
try
{
// a caller that waited on the lock reuses the token another one fetched meanwhile;
// a token that was only cached is the one the API just refused
if (_refreshes != seen && cache.TryGetValue<string>("access_token", out var cached))
{
return cached!;
}
var response = await tokenClient.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "the-service",
["client_secret"] = "...",
}), ct);
response.EnsureSuccessStatusCode();
var payload = (await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken: ct))!;
cache.Set("access_token", payload.AccessToken, TimeSpan.FromSeconds(payload.ExpiresIn - 30));
Volatile.Write(ref _refreshes, _refreshes + 1);
return payload.AccessToken;
}
finally
{
_lock.Release();
}
}
}
record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
record BareTokenResponse(string? AccessToken, int ExpiresIn);
// Stands in for whatever a real scoped dependency would be - a DbContext,
// a per-request tenant lookup - by giving each DI scope its own identity.
public sealed class RequestMarker
{
public Guid Id { get; } = Guid.NewGuid();
}
public sealed class ScopeAwareHandler(RequestMarker marker) : DelegatingHandler
{
public static readonly List<Guid> Seen = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(marker.Id);
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
public static readonly List<string> Seen = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Seen.Add(request.Headers.Authorization is null ? "request without Authorization" : "request with Authorization");
var response = await base.SendAsync(request, cancellationToken);
Seen.Add($"response {(int)response.StatusCode}");
return response;
}
}
public sealed class TokenSource(string token)
{
public int Reads { get; private set; }
public string Current
{
get
{
Reads++;
return token;
}
}
public void Renew(string fresh) => token = fresh;
}
public sealed class StubHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
public sealed class TokenEndpointStub : HttpMessageHandler
{
public const string Body = """{"access_token":"fresh-token","expires_in":3600,"token_type":"Bearer"}""";
private int _calls;
public int Calls => _calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, cancellationToken); // long enough for every caller to be waiting on the lock
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") };
}
}

compiled and run by the build

What is the right way to authenticate a .NET desktop client against an API that issues JWTs? (dotnet, howto)What is the right way toauthenticate a .NET desktop…What project structure fits a WPF app that calls a REST API with CommunityToolkit.Mvvm? (dotnet, howto)What project structure fits aWPF app that calls a REST API…What is single sign-on and how do OAuth 2.0 and OpenID Connect implement it with Microsoft Entra ID? (identity, howto)What is single sign-on and howdo OAuth 2.0 and OpenID Connect…How do I add Microsoft Entra ID sign-in to a web app or single-page app? (identity, howto)How do I add Microsoft Entra IDsign-in to a web app or…
Open in the graph

Source: https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory

History: 8 changes
  1. Example changed
  2. Body changed; example changed
  3. Example changed
  4. Body changed
  5. Body changed; example changed
  6. Made public
  7. Body changed
  8. Created