| name | token-management |
| description | Token management patterns using Duende.AccessTokenManagement. Covers client credential token caching, user token refresh, token storage, HttpClientFactory integration, DPoP support, and common configuration pitfalls. Also includes Blazor Server token management. |
| invocable | false |
Token Management
When to Use This Skill
Use this skill when:
- Building a .NET worker service or daemon that calls APIs using the client credentials flow
- Building an ASP.NET Core web application that calls APIs on behalf of the currently logged-in user
- Integrating
Duende.AccessTokenManagement or Duende.AccessTokenManagement.OpenIdConnect with IHttpClientFactory
- Configuring token caching — in-memory, distributed (Redis), or hybrid — for machine-to-machine tokens
- Adding DPoP (Demonstrating Proof-of-Possession) key binding to access tokens
- Implementing API-to-API delegation where a downstream service calls further APIs with either user tokens or client credentials
- Revoking refresh tokens on user sign-out
Core Principles
- Prefer Automatic Over Manual — Use
IHttpClientFactory-integrated clients; they acquire, cache, refresh, and attach tokens transparently. Call GetAccessTokenAsync manually only when the factory pattern is insufficient.
- Never Cache Tokens in Code — The library owns the cache. Do not store tokens in instance fields, static variables, or application-managed caches. Call the service on every request and let it serve from cache.
SaveTokens = true Is Required for User Tokens — The OIDC handler must persist tokens into the authentication session. This is the most common misconfiguration.
- Refresh Tokens Must Be Revoked at Sign-Out — Call
e.HttpContext.RevokeRefreshTokenAsync() in OnSigningOut to revoke the refresh token at the authorization server, preventing reuse after logout.
- v4 Uses
HybridCache; v3 Uses IDistributedCache — The caching layer changed between major versions. v4's HybridCache is two-tier and automatic; v3 requires an explicit AddDistributedMemoryCache() or Redis registration.
- Resiliency Is Included in
AddClientCredentialsHttpClient — This registration adds a once-retry handler for 401 Unauthorized responses (handles token expiry and DPoP nonce challenges). When using AddClientCredentialsTokenHandler directly, add it explicitly.
Related Skills
aspnetcore-authentication — cookie and OIDC handler setup required for user token management
identityserver-configuration — configuring the authorization server that issues tokens
oauth-oidc-protocols — protocol fundamentals underlying client credentials and refresh token flows
duende-bff — BFF pattern integrates this library automatically for proxied API calls
Docs: https://docs.duendesoftware.com/identityserver/tokens/management
Pattern 1: Machine-to-Machine (Client Credentials) — Worker Services
Package
dotnet add package Duende.AccessTokenManagement
Registration
services.AddClientCredentialsTokenManagement()
.AddClient("catalog.client", client =>
{
client.TokenEndpoint = new Uri("https://sts.company.com/connect/token");
client.ClientId = ClientId.Parse("6f59b670-990f-4ef7-856f-0dd584ed1fac");
client.ClientSecret = ClientSecret.Parse("d0c17c6a-ba47-4654-a874-f6d576cdf799");
client.Scope = Scope.Parse("catalog inventory");
})
.AddClient("invoice.client", client =>
{
client.TokenEndpoint = new Uri("https://sts.company.com/connect/token");
client.ClientId = ClientId.Parse("ff8ac57f-5ade-47f1-b8cd-4c2424672351");
client.ClientSecret = ClientSecret.Parse("4dbbf8ec-d62a-4639-b0db-aa5357a0cf46");
client.Scope = Scope.Parse("invoice customers");
});
Available client options:
TokenEndpoint — URL of the OAuth token endpoint
ClientId / ClientSecret — client credentials
ClientCredentialStyle — AuthorizationHeader (default) or PostBody
Scope — requested scope (optional; overridable per request)
Resource — resource indicator per RFC 8707 (optional)
HttpClientName — custom backchannel HTTP client name from the factory
DPoPJsonWebKey — JWK for DPoP-bound tokens (see Pattern 5)
Automatic via HttpClientFactory (Recommended)
services.AddClientCredentialsHttpClient(
"invoices",
ClientCredentialsClientName.Parse("invoice.client"),
client => { client.BaseAddress = new Uri("https://apis.company.com/invoice/"); });
services.AddHttpClient<CatalogClient>(client =>
{
client.BaseAddress = new Uri("https://apis.company.com/catalog/");
})
.AddClientCredentialsTokenHandler(ClientCredentialsClientName.Parse("catalog.client"));
Usage — no token code required at the call site:
public sealed class WorkerHttpClient(IHttpClientFactory factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var client = factory.CreateClient("invoices");
var response = await client.GetAsync("list", stoppingToken);
}
}
}
Resiliency handler — AddClientCredentialsHttpClient automatically adds a resiliency handler that retries once on 401 Unauthorized. This covers token expiry and DPoP nonce challenges. When using AddClientCredentialsTokenHandler directly, add it explicitly:
services.AddHttpClient<CatalogClient>(...)
.AddDefaultAccessTokenResiliency()
.AddClientCredentialsTokenHandler("catalog.client");
Manual Token Retrieval (Advanced)
public sealed class WorkerManual(
IHttpClientFactory factory,
IClientCredentialsTokenManager tokenManager) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var tokenResult = await tokenManager.GetAccessTokenAsync(
ClientCredentialsClientName.Parse("catalog.client"),
ct: stoppingToken);
if (!tokenResult.Succeeded)
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
continue;
}
var token = tokenResult.GetToken();
var client = factory.CreateClient();
client.SetBearerToken(token.AccessToken.ToString());
var response = await client.GetAsync("https://apis.company.com/catalog/list", stoppingToken);
}
}
}
In v3, the service was IClientCredentialsTokenManagementService and the result was read via .Value. In v4 it is IClientCredentialsTokenManager and the result is TokenResult<ClientCredentialsToken> — use .Succeeded / .GetToken().
Pattern 2: User Token Management — Web Applications
Package
dotnet add package Duende.AccessTokenManagement.OpenIdConnect
Registration
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "web";
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://sts.company.com";
options.ClientId = "webapp";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.ResponseMode = "query";
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("invoice");
options.Scope.Add("offline_access");
options.GetClaimsFromUserInfoEndpoint = true;
options.MapInboundClaims = false;
options.SaveTokens = true;
});
builder.Services.AddOpenIdConnectAccessTokenManagement();
SaveTokens = true is mandatory. Without it, the library cannot read or refresh the user's access token. This is the most common misconfiguration causing InvalidOperationException at runtime.
Automatic via HttpClientFactory (Recommended)
builder.Services.AddUserAccessTokenHttpClient(
"invoices",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.company.com/invoices/");
});
builder.Services.AddHttpClient<InvoiceClient>(client =>
{
client.BaseAddress = new Uri("https://api.company.com/invoices/");
})
.AddUserAccessTokenHandler();
builder.Services.AddClientAccessTokenHttpClient(
"masterdata.client",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.company.com/masterdata/");
});
builder.Services.AddHttpClient<MasterDataClient>(client =>
{
client.BaseAddress = new Uri("https://api.company.com/masterdata/");
})
.AddClientAccessTokenHandler();
Usage in a controller:
public sealed class ApiController(IHttpClientFactory httpClientFactory) : Controller
{
public async Task<IActionResult> CallApi(CancellationToken ct)
{
var client = httpClientFactory.CreateClient("invoices");
var response = await client.GetAsync("list", ct);
}
}
Manual Token Retrieval (Advanced)
public sealed class HomeController(
IHttpClientFactory httpClientFactory,
IUserTokenManager userTokenManager) : Controller
{
public async Task<IActionResult> CallApi(CancellationToken ct)
{
var token = await userTokenManager.GetAccessTokenAsync(User, ct: ct);
var client = httpClientFactory.CreateClient();
client.SetBearerToken(token.Value);
var response = await client.GetAsync("https://api.company.com/invoices", ct);
}
}
HttpContext extension methods are also available:
var userToken = await HttpContext.GetUserAccessTokenAsync();
var clientToken = await HttpContext.GetClientAccessTokenAsync();
await HttpContext.RevokeRefreshTokenAsync();
gRPC Support
Use AddUserAccessTokenHandler and AddClientAccessTokenHandler when registering typed gRPC clients:
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://grpc.company.com");
})
.AddUserAccessTokenHandler();
builder.Services.AddGrpcClient<Inventory.InventoryClient>(o =>
{
o.Address = new Uri("https://grpc.company.com");
})
.AddClientAccessTokenHandler();
Pattern 3: Token Caching
v4 — HybridCache (Default)
In v4, client credentials tokens are cached using HybridCache (ASP.NET Core 9+). It is two-tier: in-memory L1 + optional remote L2. No explicit registration is required for the default in-memory tier.
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
Global cache options:
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60;
options.CacheKeyPrefix = "MyApp.ATM::";
});
Default cache key format:
{CacheKeyPrefix}::{client_name}::hashed({scope})::hashed({resource})
scope and resource values are MD5-hashed to keep key length bounded. Implement IClientCredentialsCacheKeyGenerator to supply custom keys when adding custom TokenRequestParameters.
v3 — IDistributedCache
services.AddDistributedMemoryCache();
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "redis.company.com:6379";
});
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60;
});
Encrypting Cached Tokens (v4)
When sharing a remote cache with other applications, encrypt tokens at rest using a custom IHybridCacheSerializer<ClientCredentialsToken>:
services.AddHybridCache()
.AddSerializer<ClientCredentialsToken, EncryptedHybridCacheSerializer>();
services.AddDataProtection();
public sealed class EncryptedHybridCacheSerializer : IHybridCacheSerializer<ClientCredentialsToken>
{
private readonly IDataProtector _protector;
public EncryptedHybridCacheSerializer(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("ClientCredentialsToken");
}
public ClientCredentialsToken Deserialize(ReadOnlySequence<byte> source)
{
var unprotected = _protector.Unprotect(source.ToArray());
return JsonSerializer.Deserialize<ClientCredentialsToken>(unprotected)!;
}
public void Serialize(ClientCredentialsToken value, IBufferWriter<byte> target)
{
var json = JsonSerializer.SerializeToUtf8Bytes(value);
target.Write(_protector.Protect(json));
}
}
Scoping a Custom Cache to This Library Only
services.AddKeyedSingleton<HybridCache>(
ServiceProviderKeys.ClientCredentialsTokenCache,
new MyCustomCacheImplementation());
Pattern 4: Configuration Options
ClientCredentialsTokenManagementOptions
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60;
options.CacheKeyPrefix = "MyApp.ATM::";
});
UserTokenManagementOptions
builder.Services.AddOpenIdConnectAccessTokenManagement(options =>
{
options.ChallengeScheme = "oidc";
options.UseChallengeSchemeScopedTokens = false;
options.ClientCredentialsScope = "api1 api2";
options.ClientCredentialsResource = "urn:myapi";
options.ClientCredentialStyle = ClientCredentialStyle.PostBody;
options.DPoPJsonWebKey = jwk;
});
Per-Request Parameter Overrides
var token = await tokenManager.GetAccessTokenAsync(
ClientCredentialsClientName.Parse("catalog.client"),
new TokenRequestParameters { ForceRenewal = true },
ct: stoppingToken);
var token = await userTokenManager.GetAccessTokenAsync(
User,
new UserTokenRequestParameters
{
Scope = "invoice:write",
ForceRenewal = false,
ChallengeScheme = "oidc"
},
ct: ct);
For IHttpClientFactory clients, parameters are wired at registration time:
builder.Services.AddUserAccessTokenHttpClient(
"invoices",
parameters: new UserTokenRequestParameters { ForceRenewal = true },
configureClient: client => { client.BaseAddress = new Uri("https://api.company.com/invoices/"); });
Pattern 5: DPoP (Demonstrating Proof-of-Possession)
DPoP binds an access token to a client-held asymmetric key, preventing replay attacks even if the token is stolen. Configure it by setting DPoPJsonWebKey on the client credentials client or UserTokenManagementOptions, and optionally implement IDPoPKeyStore for runtime key rotation.
Full details in sub-document — See docs/dpop.md for JWK generation, per-client configuration, IDPoPKeyStore, session size implications, and the key-persistence pitfall.
Pattern 6: API-to-API Token Delegation
An API can either forward the user's access token to a downstream API (when the downstream accepts the same audience) or use a dedicated client credentials token (when the downstream requires a service identity).
Full details in sub-document — See docs/api-delegation.md for both approaches with complete code examples and a decision guide.
Pattern 7: Dynamic Client Configuration
Use IConfigureNamedOptions<ClientCredentialsClient> when token endpoint configuration must be resolved at runtime — for example, from OIDC discovery:
public sealed class ClientCredentialsConfigureOptions(DiscoveryCache cache)
: IConfigureNamedOptions<ClientCredentialsClient>
{
public void Configure(string? name, ClientCredentialsClient options)
{
if (name == "catalog.client")
{
var disco = cache.GetAsync().GetAwaiter().GetResult();
options.TokenEndpoint = new Uri(disco.TokenEndpoint);
options.ClientId = ClientId.Parse("...");
options.ClientSecret = ClientSecret.Parse("...");
options.Scope = Scope.Parse("catalog");
}
}
public void Configure(ClientCredentialsClient options) => Configure(string.Empty, options);
}
services.AddClientCredentialsTokenManagement();
services.AddSingleton(new DiscoveryCache("https://sts.company.com"));
services.AddSingleton<IConfigureOptions<ClientCredentialsClient>, ClientCredentialsConfigureOptions>();
Pattern 8: Custom Token Storage
User Tokens — Replace the Default Cookie Session Store
By default, user access and refresh tokens are stored inside the ASP.NET Core authentication cookie. Replace IUserTokenStore when this is insufficient — for example, when using server-side sessions:
builder.Services.AddSingleton<IUserTokenStore, ServerSideSessionUserTokenStore>();
Client Credentials — Replace the Cache Implementation
services.AddSingleton<IClientCredentialsTokenCache, MyCustomTokenCache>();
Pattern 9: Blazor Server Token Management
Blazor Server circuits outlive the initial HTTP request. Once a circuit is established, HttpContext is null, making the default cookie-based IUserTokenStore unusable. Use AddBlazorServerAccessTokenManagement<T>() with a persistent IUserTokenStore (e.g., database-backed), and capture tokens in OnTokenValidated during the initial OIDC flow.
Full details in sub-document — See docs/blazor-server.md for the full IUserTokenStore implementation, OnTokenValidated setup, and the HttpContext-null pitfall.
Pattern 10: Client Assertions (private_key_jwt)
Use IClientAssertionService to authenticate with signed JWTs instead of shared client secrets. CRITICAL: set the JWT Audience to the authorization server's issuer URL — NOT the token endpoint URL. Using the token endpoint URL was the root cause of CVE-2025-27370 and CVE-2025-27371.
Full details in sub-document — See docs/client-assertions.md for the full IClientAssertionService implementation, correct audience configuration, and CVE context.
Pattern 11: Custom Token Request Customization
Use ITokenRequestCustomizer (v4) to dynamically modify token request parameters per outgoing HTTP request — useful for multi-tenant scenarios where different tenants require different API resources or scopes. Use the with expression to create a modified copy of baseParameters; do not mutate it.
Full details in sub-document — See docs/customization.md for the full ITokenRequestCustomizer implementation and registration pattern.
Pattern 12: Custom Token Retrieval
Implement AccessTokenRequestHandler.ITokenRetriever to completely replace the default token retrieval logic with custom selection or caching behavior.
Full details in sub-document — See docs/customization.md for the full ITokenRetriever implementation and AddHttpMessageHandler registration pattern.
Sub-Documents
Load these sub-documents when the user's question specifically targets one of these areas:
| Document | Description | When to Load |
|---|
| docs/dpop.md | DPoP proof-of-possession token binding | DPoP, DPoPJsonWebKey, IDPoPKeyStore, key generation, key rotation |
| docs/blazor-server.md | Blazor Server circuit-scoped token management | Blazor, SignalR circuit, HttpContext null, AddBlazorServerAccessTokenManagement |
| docs/client-assertions.md | Client assertions (private_key_jwt) + CVE guidance | IClientAssertionService, JWT client auth, CVE-2025-27370, CVE-2025-27371 |
| docs/customization.md | ITokenRequestCustomizer & ITokenRetriever | Multi-tenant token params, custom token retrieval, AccessTokenRequestHandler |
| docs/api-delegation.md | API-to-API delegation patterns | Downstream API calls, forwarding user tokens, service identity |
Complete Example: Web App with User and Client Credentials
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "web";
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "web_app";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.Scope.Add("api1");
options.Scope.Add("offline_access");
options.SaveTokens = true;
});
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services.AddUserAccessTokenHttpClient("user-api",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
builder.Services.AddClientCredentialsTokenManagement()
.AddClient(ClientCredentialsClientName.Parse("service-client"), client =>
{
client.TokenEndpoint = new Uri("https://identity.example.com/connect/token");
client.ClientId = ClientId.Parse("web_app_service");
client.ClientSecret = ClientSecret.Parse("service_secret");
client.Scope = Scope.Parse("backend.api");
});
builder.Services.AddClientCredentialsHttpClient("service-api",
ClientCredentialsClientName.Parse("service-client"),
configureClient: client =>
{
client.BaseAddress = new Uri("https://backend.example.com");
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/user-data", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("user-api");
var response = await client.GetAsync("/data");
return Results.Ok(await response.Content.ReadAsStringAsync());
}).RequireAuthorization();
app.MapGet("/backend-data", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("service-api");
var response = await client.GetAsync("/internal/data");
return Results.Ok(await response.Content.ReadAsStringAsync());
});
app.Run();
Common Pitfalls
1. Missing SaveTokens = true for User Tokens
.AddOpenIdConnect("oidc", options =>
{
});
options.SaveTokens = true;
2. Missing offline_access Scope
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("offline_access");
3. Not Revoking Refresh Tokens at Sign-Out
.AddCookie("cookie", options =>
{
});
.AddCookie("cookie", options =>
{
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
});
4. Caching Tokens Manually Alongside the Library
private string? _cachedToken;
public async Task<string> GetToken()
{
if (_cachedToken != null) return _cachedToken;
var token = await _tokenManager.GetAccessTokenAsync(...);
_cachedToken = token.AccessToken.ToString();
return _cachedToken;
}
public async Task<string> GetToken(CancellationToken ct)
{
var result = await _tokenManager
.GetAccessTokenAsync(ClientCredentialsClientName.Parse("my.client"), ct: ct)
.GetToken();
return result.AccessToken.ToString();
}
5. Calling .GetToken() Without Checking Succeeded
var tokenResult = await tokenManager.GetAccessTokenAsync(...);
var token = tokenResult.GetToken();
var tokenResult = await tokenManager.GetAccessTokenAsync(...);
if (!tokenResult.Succeeded)
{
logger.LogError("Failed to obtain access token");
return Problem("Authentication failure", statusCode: StatusCodes.Status503ServiceUnavailable);
}
var token = tokenResult.GetToken();
6. Using AddClientCredentialsTokenHandler Without Resiliency
services.AddHttpClient<CatalogClient>(...)
.AddClientCredentialsTokenHandler("catalog.client");
services.AddHttpClient<CatalogClient>(...)
.AddDefaultAccessTokenResiliency()
.AddClientCredentialsTokenHandler("catalog.client");
7. v3 — Forgetting to Register a Distributed Cache
services.AddClientCredentialsTokenManagement()
.AddClient("catalog.client", client => { });
services.AddDistributedMemoryCache();
8. Regenerating DPoP Keys on Every Process Restart
var rsaKey = new RsaSecurityKey(RSA.Create(2048));
var jwkJson = configuration["DPoP:JsonWebKey"];
services.AddClientCredentialsTokenManagement()
.AddClient("my.client", client =>
{
client.DPoPJsonWebKey = jwkJson;
});
9. Setting Client Assertion Audience to the Token Endpoint URL
Audience = "https://identity.example.com/connect/token"
Audience = "https://identity.example.com"
CVE-2025-27370 and CVE-2025-27371 were caused by this exact mistake. Authorization servers that accept both values allow token endpoint confusion attacks.
10. Using HttpContext to Access Tokens in Blazor Server Components
var token = await HttpContext.GetUserAccessTokenAsync();
builder.Services.AddOpenIdConnectAccessTokenManagement()
.AddBlazorServerAccessTokenManagement<ServerSideTokenStore>();
11. Setting CacheLifetimeBuffer to 0
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 0;
});
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60;
});
Version Reference: v3 → v4
| Area | v3 | v4 |
|---|
| Client credentials service | IClientCredentialsTokenManagementService | IClientCredentialsTokenManager |
| User token service | IUserTokenManagementService | IUserTokenManager |
| Token result type | TokenResponse — read .Value | TokenResult<T> — use .Succeeded / .GetToken() |
| Client name type | string | ClientCredentialsClientName (strongly typed) |
| Token cache | IDistributedCache (explicit AddDistributedMemoryCache() required) | HybridCache (automatic; picks up IDistributedCache as remote L2 tier) |
| Resiliency | Manual | AddDefaultAccessTokenResiliency() built into AddClientCredentialsHttpClient |
Resources