Token Management
What is Token Management?
Section titled “What is Token Management?”When your application calls external APIs that require OAuth 2.0 tokens — payment gateways, analytics backends, partner services — you need to handle token acquisition, caching, refresh, and revocation. Without a dedicated layer, this logic gets duplicated across services, cache expiry is handled inconsistently, and DPoP proof generation becomes a maintenance burden.
Granit.Oidc.TokenManagement solves this by providing a DelegatingHandler
that plugs into IHttpClientFactory. You register a named HTTP client with its OAuth 2.0
credentials, and the handler takes care of everything: acquiring tokens via the client
credentials grant, caching them with a safety margin, retrying on 401, and optionally
binding tokens to a DPoP key pair. Your application code simply injects the named
HttpClient and makes requests — tokens are attached automatically.
This is Granit’s equivalent of Duende AccessTokenManagement, purpose-built for the Granit module system with full OpenTelemetry observability and first-class DPoP support.
When to use which package
Section titled “When to use which package”| Scenario | Package | Token type | Typical consumer |
|---|---|---|---|
| SPA user tokens via cookie-based proxy | Granit.Bff | User (authorization code + PKCE) | Browser SPA |
| Machine-to-machine API calls | Granit.Oidc.TokenManagement | Client credentials | Backend services, workers, jobs |
| Low-level token endpoint operations | Granit.Oidc | Any grant type | Custom flows, migration code |
Package structure
Section titled “Package structure”DirectoryGranit.Oidc.TokenManagement/
DirectoryCache/
- IClientCredentialsTokenCache.cs Get/set/remove cached tokens
DirectoryInternal/
- ClientCredentialsTokenCache.cs IDistributedCache-backed implementation
DirectoryDiagnostics/
- TokenManagementMetrics.cs OpenTelemetry counters
- TokenManagementActivitySource.cs Distributed tracing spans
DirectoryExtensions/
- TokenManagementServiceCollectionExtensions.cs AddGranitTokenManagement, AddClientCredentialsHttpClient
DirectoryHandlers/
- ClientCredentialsTokenHandler.cs DelegatingHandler with auto-token, 401 retry, DPoP
DirectoryOptions/
- TokenManagementOptions.cs Global settings (DefaultCacheMargin)
- ClientCredentialsOptions.cs Per-client configuration
DirectoryServices/
- ITokenEndpointService.cs Send token requests to any OIDC endpoint
- ITokenRevocationService.cs Revoke tokens (RFC 7009)
- GranitOidcTokenManagementModule.cs Module entry point
| Dependency | Role |
|---|---|
Granit.Oidc | Discovery, token request encoding, DPoP proof generation, client authentication strategies |
Granit | Module system, GranitActivitySourceRegistry |
Granit.Timing | IClock for testable time-dependent cache expiry |
Microsoft.Extensions.Caching.Abstractions | IDistributedCache for token storage |
Microsoft.Extensions.Http | IHttpClientFactory + DelegatingHandler pipeline |
Register the module in your application:
[DependsOn(typeof(GranitOidcTokenManagementModule))]public class MyServiceModule : GranitModule { }The module automatically registers ITokenEndpointService, ITokenRevocationService,
IClientCredentialsTokenCache, and the internal HTTP client used for token endpoint
communication. GranitOidcTokenManagementModule depends on
GranitOidcModule and GranitTimingModule — both are loaded transitively.
Client credentials — the main use case
Section titled “Client credentials — the main use case”The client credentials grant is designed for machine-to-machine communication: your backend service authenticates itself (not a user) to obtain an access token for a downstream API.
Configuration
Section titled “Configuration”Define each downstream API as a named client in appsettings.json:
{ "TokenManagement": { "DefaultCacheMargin": "00:00:30", "Clients": { "payment-api": { "Authority": "https://auth.example.com", "ClientId": "my-service", "ClientSecret": "secret-from-vault", "Scope": "payment:process payment:refund", "ClientAuthenticationMethod": "ClientSecretPost" }, "reporting-api": { "Authority": "https://login.microsoftonline.com/tenant-id/v2.0", "ClientId": "my-service-entra", "ClientSecret": "entra-client-secret", "Scope": "api://reporting-api/.default" } } }}DI registration
Section titled “DI registration”Register named HTTP clients in your module’s ConfigureServices:
public override void ConfigureServices(ServiceConfigurationContext context){ IConfiguration configuration = context.Services.GetConfiguration();
// Payment gateway — used by PaymentService context.Services.AddClientCredentialsHttpClient("payment-api", options => { configuration.GetSection("TokenManagement:Clients:payment-api").Bind(options); });
// Analytics backend — used by ReportingJob context.Services.AddClientCredentialsHttpClient("reporting-api", options => { configuration.GetSection("TokenManagement:Clients:reporting-api").Bind(options); });}AddClientCredentialsHttpClient returns an IHttpClientBuilder, so you can chain
additional configuration (base address, Polly policies, timeouts):
context.Services.AddClientCredentialsHttpClient("payment-api", options =>{ configuration.GetSection("TokenManagement:Clients:payment-api").Bind(options);}).ConfigureHttpClient(client =>{ client.BaseAddress = new Uri("https://api.payment-gateway.com"); client.Timeout = TimeSpan.FromSeconds(10);});Inject IHttpClientFactory and create the named client. Tokens are acquired,
cached, and attached automatically — your code never touches OAuth directly:
internal sealed class PaymentService(IHttpClientFactory httpClientFactory){ public async Task<PaymentResult> ProcessPaymentAsync( PaymentRequest payment, CancellationToken cancellationToken) { HttpClient client = httpClientFactory.CreateClient("payment-api");
// The Authorization header is attached automatically by ClientCredentialsTokenHandler HttpResponseMessage response = await client.PostAsJsonAsync( "/v2/payments", payment, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync<PaymentResult>(cancellationToken).ConfigureAwait(false))!; }}How it works
Section titled “How it works”The ClientCredentialsTokenHandler sits in the IHttpClientFactory pipeline and
intercepts every outbound request. It checks the cache first, acquires a token if
needed, and handles 401 retries transparently.
sequenceDiagram
participant App as Application Code
participant Handler as ClientCredentialsTokenHandler
participant Cache as IClientCredentialsTokenCache
participant IdP as Token Endpoint
participant API as Downstream API
Note over App: First request
App->>Handler: GET /v2/payments
Handler->>Cache: GetTokenAsync("payment-api")
Cache-->>Handler: null (cache miss)
Handler->>IdP: POST /connect/token<br/>grant_type=client_credentials
IdP-->>Handler: { access_token, expires_in: 3600 }
Handler->>Cache: SetTokenAsync("payment-api", token, 3570s)
Handler->>API: GET /v2/payments<br/>Authorization: Bearer {token}
API-->>Handler: 200 OK
Handler-->>App: 200 OK
Note over App: Second request (within cache window)
App->>Handler: GET /v2/payments/123
Handler->>Cache: GetTokenAsync("payment-api")
Cache-->>Handler: {cached token} (cache hit)
Handler->>API: GET /v2/payments/123<br/>Authorization: Bearer {token}
API-->>Handler: 200 OK
Handler-->>App: 200 OK
Note over App: Token revoked server-side
App->>Handler: POST /v2/refunds
Handler->>Cache: GetTokenAsync("payment-api")
Cache-->>Handler: {stale token}
Handler->>API: POST /v2/refunds<br/>Authorization: Bearer {stale token}
API-->>Handler: 401 Unauthorized
Handler->>Cache: RemoveTokenAsync("payment-api")
Handler->>IdP: POST /connect/token<br/>grant_type=client_credentials
IdP-->>Handler: { access_token, expires_in: 3600 }
Handler->>Cache: SetTokenAsync("payment-api", token, 3570s)
Handler->>API: POST /v2/refunds<br/>Authorization: Bearer {fresh token}
API-->>Handler: 200 OK
Handler-->>App: 200 OK
Key behaviors:
- Cache-first: the handler always checks the distributed cache before hitting the token endpoint. A cache hit avoids a network round-trip entirely.
- Automatic retry on 401: if the downstream API rejects a token, the handler invalidates the cache entry, acquires a fresh token, and retries the request exactly once. The original request is cloned (including body) for the retry.
- Thread-safe: cache operations use
IDistributedCachewhich is inherently thread-safe. DPoP key state is protected bySystem.Threading.Lock.
DPoP integration
Section titled “DPoP integration”When UseDPoP is enabled, the handler generates an EC P-256 key pair on first use
and creates a DPoP proof JWT for every request. The token endpoint receives the proof
during acquisition, binding the access token to the client’s key via the cnf claim.
Downstream APIs receive both the DPoP-bound token and a fresh proof.
{ "TokenManagement": { "Clients": { "payment-api": { "Authority": "https://auth.example.com", "ClientId": "my-service", "ClientSecret": "secret-from-vault", "Scope": "payment:process", "UseDPoP": true } } }}Without DPoP:
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...With DPoP:
Authorization: DPoP eyJhbGciOiJSUzI1NiIs...DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7...The DPoP header contains a signed proof JWT. Even if the access token is stolen,
it cannot be used without the private key that generated the proof.
The handler automatically:
- Generates an EC P-256 key pair (once per handler instance, via
IDPoPProofService) - Includes the DPoP proof when requesting tokens from the IdP
- Stores the server-provided nonce (if any) for subsequent proofs
- Attaches
Authorization: DPoP+DPoP: {proof}headers on every API call
Private Key JWT authentication
Section titled “Private Key JWT authentication”For environments where shared secrets are not acceptable (FAPI 2.0, Open Banking),
use private_key_jwt client authentication. Instead of sending a client_secret,
the handler signs a JWT assertion with a private key and sends it to the token
endpoint.
{ "TokenManagement": { "Clients": { "payment-api": { "Authority": "https://auth.example.com", "ClientId": "my-service", "ClientAuthenticationMethod": "PrivateKeyJwt", "ClientSigningKeyJwk": "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"...\",\"x\":\"...\",\"y\":\"...\"}", "Scope": "payment:process" } } }}| Property | Description |
|---|---|
ClientAuthenticationMethod | Set to PrivateKeyJwt to enable JWT assertion authentication |
ClientSigningKeyJwk | The EC or RSA private key in JWK format. Used to sign the client_assertion JWT |
The PrivateKeyJwtStrategy (from Granit.Oidc) generates a short-lived
JWT assertion with the client ID as sub and iss, the token endpoint as aud, and
a unique jti. The assertion is sent as client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
Token revocation
Section titled “Token revocation”For scenarios where you need to explicitly invalidate a token (user logout, key rotation,
compliance requirements), inject ITokenRevocationService:
internal sealed class SessionCleanupService( ITokenRevocationService revocationService){ public async Task RevokeServiceTokenAsync( string authority, string token, CancellationToken cancellationToken) { bool revoked = await revocationService.RevokeTokenAsync( authority, new RevocationRequest { ClientId = "my-service", Token = token, TokenTypeHint = "access_token", }, cancellationToken: cancellationToken).ConfigureAwait(false);
// revoked == true if the revocation endpoint returned 2xx // revoked == false if the endpoint is unavailable or returned an error }}The service resolves the revocation endpoint from the authority’s OIDC discovery
document. If the IdP does not publish a revocation_endpoint, the call returns
false and logs a warning — it does not throw. This is by design: token revocation
is best-effort per RFC 7009.
Cache margin
Section titled “Cache margin”Access tokens have a finite lifetime (expires_in). If a token is cached until its
exact expiry, requests sent in the last few seconds may arrive at the downstream API
with an already-expired token — especially across clock-skewed servers.
The cache margin subtracts a safety buffer from the token’s lifetime before storing it:
cache_duration = expires_in - cache_marginFor example, with a token that expires in 3600 seconds and a 30-second margin, the cached entry lives for 3570 seconds. The token is refreshed 30 seconds before it would expire.
| Setting | Scope | Default | Description |
|---|---|---|---|
TokenManagement:DefaultCacheMargin | Global | 30 seconds | Applied to all clients unless overridden |
ClientCredentialsOptions.CacheMargin | Per client | null (uses global) | Override for a specific named client |
{ "TokenManagement": { "DefaultCacheMargin": "00:00:30", "Clients": { "payment-api": { "CacheMargin": "00:01:00" } } }}Observability
Section titled “Observability”Metrics
Section titled “Metrics”All counters are registered under the Granit.Oidc.TokenManagement meter
and follow the Granit metrics conventions.
| Metric | Type | Tags | Description |
|---|---|---|---|
granit.oidc.token_management.request.sent | Counter | tenant_id, grant_type, client_name | Token endpoint requests sent |
granit.oidc.token_management.cache.hit | Counter | tenant_id, client_name | Token served from cache |
granit.oidc.token_management.cache.miss | Counter | tenant_id, client_name | Token not in cache, endpoint call required |
granit.oidc.token_management.revocation.sent | Counter | tenant_id | Successful token revocations |
granit.oidc.token_management.error.occurred | Counter | tenant_id, error_type | Token endpoint errors (network, protocol) |
Distributed tracing
Section titled “Distributed tracing”The Granit.Oidc.TokenManagement ActivitySource emits spans for:
| Operation | Span name | Description |
|---|---|---|
| Cache lookup | token-management.cache-lookup | Check if a valid token exists in cache |
| Token request | token-management.request-token | Full round-trip to the token endpoint |
| Token revocation | token-management.revoke-token | Revocation endpoint call |
Spans are automatically collected by Granit.Observability via GranitActivitySourceRegistry.
See also
Section titled “See also”- Authentication — JWT Bearer, Keycloak, Entra ID, Cognito
- DPoP Resource Server Validation — Validating DPoP proofs on the receiving end
- DPoP — Proof-of-Possession — BFF-side key generation and proof creation
- FAPI 2.0 Security Profile — PAR, DPoP, private_key_jwt, PKCE combined
- BFF — Cookie-based proxy for browser SPAs (user tokens)
- OpenIddict — Self-hosted OIDC server with OpenIddict