Microsoft.Identity.Web's built-in MsalMemoryTokenCacheProvider (registered via
services.AddInMemoryTokenCaches()) exposes a virtual GetSuggestedCacheKey(TokenCacheNotificationArgs args)
method that looks like a natural extension point for customizing the cache key (e.g. to partition
by audience/resource, in addition to the default homeAccountId / ClientId+TenantId key).
However, this hook is not actually invoked when using the standard ASP.NET Core DI-based
in-memory cache. In TokenAcquisition.BuildConfidentialClientApplicationAsync
(src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs), the wiring of the MSAL cache
callbacks is explicitly skipped for MsalMemoryTokenCacheProvider (and any subclass, since is
matches derived types):
// Initialize token cache providers
if (!(_tokenCacheProvider is MsalMemoryTokenCacheProvider))
{
_tokenCacheProvider.Initialize(app.AppTokenCache);
_tokenCacheProvider.Initialize(app.UserTokenCache);
}Initialize() is what calls tokenCache.SetBeforeAccessAsync/SetAfterAccessAsync/SetBeforeWriteAsync
on the MSAL ITokenCache — i.e. it's what makes GetSuggestedCacheKey, ReadCacheBytesAsync, and
WriteCacheBytesAsync get invoked at all. When the registered provider is (or derives from)
MsalMemoryTokenCacheProvider, this wiring is skipped entirely, and MSAL instead relies solely on
its own internal shared/static in-memory cache (CacheOptions.EnableSharedCacheOptions, set a few
lines above), which is opaque and offers no partitioning hook.
The only place MsalMemoryTokenCacheProvider.Initialize() is actually called is the standalone,
non-DI IConfidentialClientApplication.AddInMemoryTokenCache() extension
(src/Microsoft.Identity.Web.TokenCache/TokenCacheExtensions.cs), which is explicitly documented as
"Don't use this method in ASP.NET Core."
Initialize() is called for any provider that is not MsalMemoryTokenCacheProvider. So, to get
an actually-invoked, customizable cache key while staying fully in-memory, use
MsalDistributedTokenCacheAdapter backed by ASP.NET Core's in-memory IDistributedCache
implementation (services.AddDistributedMemoryCache()). The adapter still keeps its own built-in L1
MemoryCache in front of the distributed cache
(see MsalDistributedTokenCacheAdapterOptions.L1CacheOptions), so this remains a purely in-process,
two-level in-memory cache — just wired through a type that isn't excluded from Initialize().
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web.TokenCacheProviders;
using Microsoft.Identity.Web.TokenCacheProviders.Distributed;
/// <summary>
/// Distributed-cache-backed MSAL token cache adapter that additionally partitions the
/// cache key by the audience (resource) of the requested token, so tokens for different
/// downstream APIs never collide or evict each other in the same cache instance.
/// </summary>
public class AudiencePartitionedDistributedTokenCacheAdapter : MsalDistributedTokenCacheAdapter
{
public AudiencePartitionedDistributedTokenCacheAdapter(
IDistributedCache distributedCache,
IOptions<MsalDistributedTokenCacheAdapterOptions> cacheOptions,
ILogger<MsalDistributedTokenCacheAdapter> logger)
: base(distributedCache, cacheOptions, logger)
{
}
public override string GetSuggestedCacheKey(TokenCacheNotificationArgs args)
{
// Base key: homeAccountId (user cache) or ClientId+TenantId (app cache)
string baseKey = base.GetSuggestedCacheKey(args);
string audience = GetAudience(args);
return string.IsNullOrEmpty(audience)
? baseKey
: $"{baseKey}__aud={audience}";
}
// Derives an "audience" partition from the requested scopes.
// For v2 scopes like "https://graph.microsoft.com/.default" or
// "api://<app-id>/access_as_user", the audience is the scheme+host (or app ID URI).
private static string GetAudience(TokenCacheNotificationArgs args)
{
string? scope = args.RequestScopes?.FirstOrDefault(s =>
!s.Equals("openid", StringComparison.OrdinalIgnoreCase) &&
!s.Equals("profile", StringComparison.OrdinalIgnoreCase) &&
!s.Equals("offline_access", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(scope))
{
return string.Empty;
}
// "https://graph.microsoft.com/.default" -> "https://graph.microsoft.com"
// "api://<app-id>/access_as_user" -> "api://<app-id>"
int lastSlash = scope.LastIndexOf('/');
return lastSlash > 0 ? scope[..lastSlash] : scope;
}
}
public static class AudiencePartitionedCacheExtensions
{
public static IServiceCollection AddAudiencePartitionedInMemoryTokenCaches(this IServiceCollection services)
{
services.AddDistributedMemoryCache(); // in-memory IDistributedCache backing store
services.TryAddSingleton<IMsalTokenCacheProvider, AudiencePartitionedDistributedTokenCacheAdapter>();
return services;
}
}Registration in Program.cs:
builder.Services.AddAuthentication(...)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi()
.AddAudiencePartitionedInMemoryTokenCaches(); // instead of AddInMemoryTokenCaches()- Overriding
MsalMemoryTokenCacheProvider.GetSuggestedCacheKeyhas no effect in the standard ASP.NET Core DI flow (AddInMemoryTokenCaches()+TokenAcquisition), becauseInitialize()is never called for that provider type there. - To customize the cache key (e.g. partition by audience/resource), use
MsalDistributedTokenCacheAdapterwithservices.AddDistributedMemoryCache()as the backing store instead — it stays fully in-process/in-memory but is not excluded fromInitialize(). TokenCacheNotificationArgs.RequestScopesgives you the requested scopes for the current operation, which you can use to derive an audience/resource partition key.