Skip to content

Instantly share code, notes, and snippets.

@bgavrilMS
Created July 22, 2026 21:00
Show Gist options
  • Select an option

  • Save bgavrilMS/624c3cde8cb7874f9cc0136b7df51e0b to your computer and use it in GitHub Desktop.

Select an option

Save bgavrilMS/624c3cde8cb7874f9cc0136b7df51e0b to your computer and use it in GitHub Desktop.
Microsoft.Identity.Web: partitioned in-memory MSAL token cache keyed by audience

Partitioned in-memory MSAL token cache, keyed by audience (Microsoft.Identity.Web)

Context

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

The working alternative

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()

Key takeaways

  • Overriding MsalMemoryTokenCacheProvider.GetSuggestedCacheKey has no effect in the standard ASP.NET Core DI flow (AddInMemoryTokenCaches() + TokenAcquisition), because Initialize() is never called for that provider type there.
  • To customize the cache key (e.g. partition by audience/resource), use MsalDistributedTokenCacheAdapter with services.AddDistributedMemoryCache() as the backing store instead — it stays fully in-process/in-memory but is not excluded from Initialize().
  • TokenCacheNotificationArgs.RequestScopes gives you the requested scopes for the current operation, which you can use to derive an audience/resource partition key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment