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.
The handler and its registration
Section titled “The handler and its registration”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:
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
#: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:
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
#: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 ownReaching 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:
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
#: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.
Chaining more than one handler
Section titled “Chaining more than one handler”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 -.-> Cbuilder.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 handlercompiled and run by the build
Show the whole file
#: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]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
#: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.AccessTokenManagementpackage) wires aclient_credentialsclient straight intoIHttpClientFactory:AddClientCredentialsTokenManagement()plus oneAddClient(...)call registers the token endpoint, its scopes and its credentials, andAddClientCredentialsHttpClient(name, clientName, ...)- or, on a typed client,AddHttpClient<T>().AddClientCredentialsTokenHandler()- returns anHttpClientthat requests, caches and renews its own token with noDelegatingHandlerorITokenProviderof its own; its handler replacesBearerTokenHandlerfor that client rather than plugging into it. - MSAL’s
AcquireTokenForClient, on aConfidentialClientApplication, is theclient_credentialscounterpart to theAcquireTokenSilentcalldotnet/jwt-authentication-desktop-clientuses 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 overDuende.AccessTokenManagementmainly 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:
services.AddHttpClient("widgets", client => client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Current));compiled and run by the build
Show the whole file
#: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.
How to verify it worked
Section titled “How to verify it worked”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):
var stub = new StubHandler(); // records the last HttpRequestMessage, returns HttpStatusCode.OKvar 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
#: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.
When this does not apply
Section titled “When this does not apply”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
<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
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
#: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
Source: https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory
History: 8 changes
- Example changed
- Body changed; example changed
- Example changed
- Body changed
- Body changed; example changed
- Made public
- Body changed
- Created