Skip to content

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.

ScenarioPackageToken typeTypical consumer
SPA user tokens via cookie-based proxyGranit.BffUser (authorization code + PKCE)Browser SPA
Machine-to-machine API callsGranit.Oidc.TokenManagementClient credentialsBackend services, workers, jobs
Low-level token endpoint operationsGranit.OidcAny grant typeCustom flows, migration code
  • 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
DependencyRole
Granit.OidcDiscovery, token request encoding, DPoP proof generation, client authentication strategies
GranitModule system, GranitActivitySourceRegistry
Granit.TimingIClock for testable time-dependent cache expiry
Microsoft.Extensions.Caching.AbstractionsIDistributedCache for token storage
Microsoft.Extensions.HttpIHttpClientFactory + 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.

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.

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"
}
}
}
}

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))!;
}
}

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 IDistributedCache which is inherently thread-safe. DPoP key state is protected by System.Threading.Lock.

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
}
}
}
}

The handler automatically:

  1. Generates an EC P-256 key pair (once per handler instance, via IDPoPProofService)
  2. Includes the DPoP proof when requesting tokens from the IdP
  3. Stores the server-provided nonce (if any) for subsequent proofs
  4. Attaches Authorization: DPoP + DPoP: {proof} headers on every API call

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"
}
}
}
}
PropertyDescription
ClientAuthenticationMethodSet to PrivateKeyJwt to enable JWT assertion authentication
ClientSigningKeyJwkThe 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.

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.

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_margin

For 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.

SettingScopeDefaultDescription
TokenManagement:DefaultCacheMarginGlobal30 secondsApplied to all clients unless overridden
ClientCredentialsOptions.CacheMarginPer clientnull (uses global)Override for a specific named client
{
"TokenManagement": {
"DefaultCacheMargin": "00:00:30",
"Clients": {
"payment-api": {
"CacheMargin": "00:01:00"
}
}
}
}

All counters are registered under the Granit.Oidc.TokenManagement meter and follow the Granit metrics conventions.

MetricTypeTagsDescription
granit.oidc.token_management.request.sentCountertenant_id, grant_type, client_nameToken endpoint requests sent
granit.oidc.token_management.cache.hitCountertenant_id, client_nameToken served from cache
granit.oidc.token_management.cache.missCountertenant_id, client_nameToken not in cache, endpoint call required
granit.oidc.token_management.revocation.sentCountertenant_idSuccessful token revocations
granit.oidc.token_management.error.occurredCountertenant_id, error_typeToken endpoint errors (network, protocol)

The Granit.Oidc.TokenManagement ActivitySource emits spans for:

OperationSpan nameDescription
Cache lookuptoken-management.cache-lookupCheck if a valid token exists in cache
Token requesttoken-management.request-tokenFull round-trip to the token endpoint
Token revocationtoken-management.revoke-tokenRevocation endpoint call

Spans are automatically collected by Granit.Observability via GranitActivitySourceRegistry.