AuthorizationInterceptor 6.3.0

dotnet add package AuthorizationInterceptor --version 6.3.0
                    
NuGet\Install-Package AuthorizationInterceptor -Version 6.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AuthorizationInterceptor" Version="6.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AuthorizationInterceptor" Version="6.3.0" />
                    
Directory.Packages.props
<PackageReference Include="AuthorizationInterceptor" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AuthorizationInterceptor --version 6.3.0
                    
#r "nuget: AuthorizationInterceptor, 6.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AuthorizationInterceptor@6.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AuthorizationInterceptor&version=6.3.0
                    
Install as a Cake Addin
#tool nuget:?package=AuthorizationInterceptor&version=6.3.0
                    
Install as a Cake Tool

AuthorizationInterceptor Icon

Authorization Interceptor

A lightweight .NET library that keeps your HttpClient authenticated for you. When a request comes back 401 Unauthorized, the interceptor re-authenticates, refreshes the headers, and retries the request — automatically. No manual token juggling.

GitHub Actions License: MIT Codecov NuGet Version .NET Support

Quick Start

Only two steps are required: tell the interceptor how to authenticate, then attach it to an HttpClient.

1. Install the package

dotnet add package AuthorizationInterceptor

2. Write a handler that returns your authorization headers

AuthorizationHeaders is just a string dictionary — return whatever headers your API needs.

public class TargetApiAuth : IAuthenticationHandler
{
    private readonly HttpClient _client;

    public TargetApiAuth(IHttpClientFactory factory)
        => _client = factory.CreateClient("Auth");

    public async ValueTask<AuthorizationHeaders?> AuthenticateAsync(
        AuthorizationHeaders? expiredHeaders, CancellationToken ct)
    {
        var response = await _client.PostAsync("auth", content: null, ct);
        var token = await response.Content.ReadAsStringAsync(ct);

        return new AuthorizationHeaders
        {
            ["Authorization"] = $"Bearer {token}"
        };
    }
}

3. Attach it to your HttpClient

builder.Services.AddHttpClient("TargetApi")
    .AddAuthorizationInterceptorHandler<TargetApiAuth>()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://targetapi.com"));

That's it. Every call made through this HttpClient now carries the authorization headers, and any 401 triggers a re-authentication and retry — transparently.

AuthenticateAsync is called the first time headers are needed and again whenever a request is rejected. On a rejection, the previously used headers are passed back in as expiredHeaders (see OAuth & refresh tokens below).

Features

  • Automatic retry on auth failure — intercepts 401 responses and retries with fresh headers.
  • Built-in OAuth2 Client Credentials — authenticate services with a client ID and secret without writing a custom handler.
  • Any headers you want — return a simple dictionary, or use the built-in OAuth2 helper.
  • OAuth2 refresh tokens — reuse existing tokens through the RefreshToken flow.
  • Caching — in-memory, distributed (Redis/NCache), or hybrid, to avoid redundant logins.
  • Deduplicated authentication — concurrent requests share a single authentication call.
  • Local & distributed locking — coalesce authentication within an instance, and optionally across instances.
  • .NET 8, 9, and 10.

OAuth & refresh tokens

If your API issues OAuth2 tokens, return OAuthHeaders instead of a raw dictionary. The interceptor then knows the token's lifetime (so it can cache and expire it) and hands the expired headers back to you so you can refresh instead of logging in again.

public async ValueTask<AuthorizationHeaders?> AuthenticateAsync(
    AuthorizationHeaders? expiredHeaders, CancellationToken ct)
{
    // expiredHeaders is null on the first authentication, and populated on a refresh.
    var response = expiredHeaders?.OAuthHeaders?.RefreshToken is { } refreshToken
        ? await _client.PostAsync($"refresh?refresh={refreshToken}", content: null, ct)
        : await _client.PostAsync("auth", content: null, ct);

    var json = await response.Content.ReadAsStringAsync(ct);
    var tokens = JsonSerializer.Deserialize<UserTokens>(json)!;

    // (AccessToken, TokenType, ExpiresIn, RefreshToken, ExpiresInRefreshToken)
    return new OAuthHeaders(
        tokens.AccessToken, tokens.TokenType, tokens.ExpiresIn,
        tokens.RefreshToken, tokens.RefreshAccessTokenExpiresIn);
}

public record UserTokens(
    string AccessToken, string TokenType, int ExpiresIn,
    string RefreshToken, int RefreshAccessTokenExpiresIn);

OAuthHeaders becomes a standard Authorization: {TokenType} {AccessToken} header automatically. Only AccessToken and TokenType are required; the rest are optional. The refresh branch is only needed if your provider supports refresh tokens — otherwise just re-authenticate.

Built-in OAuth2 Client Credentials

For machine-to-machine integrations, use the built-in Client Credentials handler instead of implementing IAuthenticationHandler. This works with OAuth 2.0 providers such as Keycloak:

builder.Services.AddHttpClient("OrdersApi")
    .AddClientCredentialsAuthorizationInterceptorHandler(
        auth =>
        {
            auth.TokenEndpoint = new Uri(
                "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token");
            auth.ClientId = builder.Configuration["OrdersApi:ClientId"]!;
            auth.ClientSecret = builder.Configuration["OrdersApi:ClientSecret"]!;
        },
        interceptor => interceptor.UseHybridCacheInterceptor())
    .ConfigureHttpClient(client =>
        client.BaseAddress = new Uri("https://orders.example.com"));

The handler requests and converts the token into regular OAuthHeaders, so caching, expiration, locking, 401 handling, and retry use the existing interceptor pipeline. Client credentials use HTTP Basic by default; scopes, form-body authentication, and additional parameters are also supported. See OAuth2ClientCredentialsOptions for all configuration options.

Token requests use a dedicated client named {HttpClientName}OAuth2ClientCredentialsAuthenticationHandler. Remote token endpoints must use HTTPS, except for loopback addresses. Keep client secrets in a secure configuration provider rather than source code.

Without a cache, a fresh token is requested on every expiration. Add one cache interceptor and tokens are reused until they expire.

Package Use case
AuthorizationInterceptor.Extensions.MemoryCache Single-instance apps
AuthorizationInterceptor.Extensions.DistributedCache Multi-instance (Redis, NCache, …)
AuthorizationInterceptor.Extensions.HybridCache Memory + distributed — recommended

Enable it in the options callback — e.g. hybrid caching:

builder.Services.AddHttpClient("TargetApi")
    .AddAuthorizationInterceptorHandler<TargetApiAuth>(options =>
    {
        options.UseHybridCacheInterceptor();
    })
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://targetapi.com"));

Hybrid caching checks in-memory first (fastest), falls back to the distributed cache (shared across instances), and only then calls your handler. Swap in UseMemoryCacheInterceptor() or UseDistributedCacheInterceptor() if you prefer one layer.

Advanced

<details open> <summary><strong>Concurrency & deduplication</strong></summary>

When several requests need headers at the same time and none are cached, only one of them calls your handler. The others wait and reuse the result. The same applies after a 401: a request only re-authenticates if no one else has already replaced the rejected token.

This deduplication is enabled with a local lock (AuthenticationLockMode.Local), scoped to the process and to the HttpClient name (plus any CacheKeyBuilder suffix). Locking is disabled by default (AuthenticationLockMode.None). Across instances it's the shared cache — not a lock — that keeps logins down; with a cold distributed cache two instances can still authenticate at once. If your provider invalidates the previous token on every issuance, add a distributed lock.

Deduplication requires at least one cache interceptor. Without one there is nothing to share, so every request authenticates on its own.

</details>

<details open> <summary><strong>Locking across instances</strong></summary>

This is what protects you from a cache stampede: when the shared token expires (or the cache is cold) and many instances suddenly hit the API at once, without a distributed lock they would all authenticate simultaneously, hammering the auth provider with duplicate logins. A distributed lock lets a single instance authenticate while the others wait and reuse its result.

LockMode controls how concurrent authentications are serialized. It's a [Flags] enum, so scopes combine:

Mode Prevents concurrent authentication…
AuthenticationLockMode.None not at all (default)
AuthenticationLockMode.Local within a single instance
AuthenticationLockMode.Distributed across multiple instances
Local \| Distributed both (recommended when scaling out)

The distributed lock builds on DistributedLock.Core, which is only the abstraction — you choose and register the provider (Redis, SQL Server, Postgres, Azure, FileSystem, …):

dotnet add package DistributedLock.Redis
using Medallion.Threading;
using Medallion.Threading.Redis;
using StackExchange.Redis;

var multiplexer = ConnectionMultiplexer.Connect("localhost:6379");
builder.Services.AddSingleton<IDistributedLockProvider>(_ =>
    new RedisDistributedSynchronizationProvider(multiplexer.GetDatabase()));

builder.Services.AddHttpClient("TargetApi")
    .AddAuthorizationInterceptorHandler<TargetApiAuth>(options =>
    {
        options.UseHybridCacheInterceptor();
        options.LockMode = AuthenticationLockMode.Local | AuthenticationLockMode.Distributed;
        options.DistributedLockTimeout = TimeSpan.FromSeconds(30); // optional; null waits indefinitely
    });

With the distributed lock enabled, only one instance authenticates for a given key at a time. After acquiring the lock it re-checks the shared cache (double-checked locking): if another instance already refreshed the headers, it adopts them and skips the login. Pair it with a distributed (or hybrid) cache so there's a shared cache for that re-check to hit.

If LockMode includes Distributed but no IDistributedLockProvider is registered, creating the HttpClient throws InvalidOperationException.

</details>

<details open> <summary><strong>Customizing what counts as "unauthorized"</strong></summary>

By default a response only triggers re-authentication when its status code is 401 Unauthorized. But UnauthenticatedPredicate is a full Func<HttpResponseMessage, bool>, so you decide what "unauthorized" means for your API — it isn't limited to status codes. You get the whole response, so you can inspect the status, a header, or even the response body.

Most common case — also treat 403 as expired:

.AddAuthorizationInterceptorHandler<TargetApiAuth>(options =>
{
    options.UnauthenticatedPredicate = response =>
        response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized;
});

Some APIs answer 200 OK with an error in a header or in the payload. You can key off those instead:

// Based on a custom header
options.UnauthenticatedPredicate = response =>
    response.Headers.TryGetValues("x-auth-status", out var values)
        && values.Contains("expired");

// Based on the response body
options.UnauthenticatedPredicate = response =>
{
    var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
    return body.Contains("token_expired");
};

Return true for any response that means "the credentials are no longer valid" and the interceptor will re-authenticate and retry.

</details>

<details open> <summary><strong>Passing extra dependencies into a handler</strong></summary>

Use the delegate overload when your handler needs values that aren't in DI:

.AddAuthorizationInterceptorHandler(sp =>
    ActivatorUtilities.CreateInstance<TargetApiAuth>(sp, someOtherDependency));

</details>

<details open> <summary><strong>Per-request cache keys</strong></summary>

When one HttpClient authenticates on behalf of different users, tenants, stores, etc., use CacheKeyBuilder to cache their headers separately. The returned value is appended to the cache key.

.AddAuthorizationInterceptorHandler<TargetApiAuth>(options =>
{
    options.UseHybridCacheInterceptor();
    options.CacheKeyBuilder = accessor =>
        accessor.HttpContext?.User.FindFirst("sub")?.Value;
});

Or from a route/query value:

options.CacheKeyBuilder = accessor =>
{
    var http = accessor.HttpContext;
    var storeId = http?.Request.RouteValues["storeId"]?.ToString()
        ?? http?.Request.Query["storeId"].ToString();
    return string.IsNullOrWhiteSpace(storeId) ? null : storeId;
};

Requests with different values no longer share cached headers. If the builder returns null/empty, the default key (the HttpClient name) is used. Interceptors receive the two halves combined as a single key: just the name when no suffix applies, or {name}_{suffix} when one does.

</details>

<details open> <summary><strong>Custom interceptors</strong></summary>

Add your own steps to the interceptor chain — e.g. logging or a custom cache backend:

.AddAuthorizationInterceptorHandler<TargetApiAuth>(options =>
{
    options.UseMemoryCacheInterceptor();
    options.UseCustomInterceptor<MyLoggingInterceptor>();
});
public class MyLoggingInterceptor : IAuthorizationInterceptor
{
    public ValueTask<AuthorizationHeaders?> GetHeadersAsync(
        string key, CancellationToken ct)
        => new(new AuthorizationHeaders());

    public ValueTask UpdateHeadersAsync(
        string key, AuthorizationHeaders? expiredHeaders,
        AuthorizationHeaders? newHeaders, CancellationToken ct)
    {
        // Log or transform headers between cache and auth handler
        return default;
    }
}

The chain becomes MemoryCache → MyLoggingInterceptor → AuthHandler → MyLoggingInterceptor → MemoryCache. Build a custom cache backend by targeting AuthorizationInterceptor.Extensions.Abstractions.

</details>

Sample Applications

Run a working demo with a mock API endpoint:

cd samples
dotnet run --project TargetApi   # starts mock auth server on :5001
# in another terminal:
dotnet run --project SourceApi   # calls the mock API with interceptor enabled

Source: Samples

License

This project is licensed under the MIT License. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
6.3.0 57 9/25/2026
6.2.0 322 8/14/2026
6.1.0 105 8/10/2026
6.0.1 267 6/16/2026
6.0.0 119 6/15/2026
5.4.1 22,856 2/12/2026
5.4.0 143 1/19/2026
5.2.0 16,642 9/18/2025
5.0.1 238 9/12/2025
5.0.0 260 3/18/2025
5.0.0-preview-1 231 3/17/2025
5.0.0-beta 230 3/17/2025
4.0.0 198 2/28/2025
2.2.0 239 11/12/2024
2.1.2 174 11/6/2024
2.1.0 465 4/27/2024
2.0.0 265 4/24/2024 2.0.0 is deprecated because it is no longer maintained.
2.0.0-beta1 261 4/24/2024 2.0.0-beta1 is deprecated because it is no longer maintained.
1.2.1-beta1 353 4/8/2024 1.2.1-beta1 is deprecated because it is no longer maintained.
1.0.0 253 4/9/2024 1.0.0 is deprecated because it is no longer maintained.
Loading failed