원클릭으로
msal-cache-handling
Handle MSAL distributed token cache collisions and stale entries in ASP.NET Core applications
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Handle MSAL distributed token cache collisions and stale entries in ASP.NET Core applications
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Authorization Code Flow for web applications using MSAL.NET confidential client to sign in users and access APIs on their behalf
On-Behalf-Of (OBO) Flow for web APIs to call downstream APIs while preserving user identity in MSAL.NET
{what this skill teaches agents}
Review API DTO implementations for contract/domain separation, mapping patterns, and REST compliance
This skill should be used when the user asks to "build a feature", "fix a bug", "implement something", "start a dev cycle", types "/dev", or describes a software task that requires design, implementation, testing, and shipping. Orchestrates the full software development lifecycle from interrogation through shipping, with self-learning that improves over time.
Frontend JavaScript patterns for event handling, form UX, and validation integration
| name | msal-cache-handling |
| description | Handle MSAL distributed token cache collisions and stale entries in ASP.NET Core applications |
Domain: Authentication, MSAL, Token Cache, Microsoft.Identity.Web
Language: C#
Framework: ASP.NET Core, Microsoft.Identity.Web
Robust pattern for handling MSAL token cache collisions and stale entries in ASP.NET Core applications using SQL-backed distributed token caches. Prevents "Token already exists in cache" errors on app recycles.
⚠️ Project-specific constraint: In
RejectSessionCookieWhenAccountNotInCacheEvents.ValidatePrincipal, callcontext.RejectPrincipal()only — do NOT callcontext.HttpContext.SignOutAsync(). CallingSignOutAsync()inside the cookie validation event creates an authentication redirect loop in this project. The sample code below showsSignOutAsync()as it is a general pattern, but it must be omitted here.
📢 Project logging directive: Do NOT add
MinimumLevel.Overridesuppressions forMicrosoft.Identity,Microsoft.IdentityModel, orMSALnamespaces in DEBUG builds. Verbose MSAL debug output is intentionally kept visible locally — it enables detection of cache miss issues and other auth problems. Production (#else) suppression viaMinimumLevel.Override("Microsoft", ...)is correct and must remain.
AddDistributedSqlServerCache()using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
internal class RejectSessionCookieWhenAccountNotInCacheEvents : CookieAuthenticationEvents
{
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
try
{
var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService<ITokenAcquisition>();
var token = await tokenAcquisition.GetAccessTokenForUserAsync(
scopes: new[] { "profile" },
user: context.Principal);
}
catch (MicrosoftIdentityWebChallengeUserException ex) when (AccountDoesNotExitInTokenCache(ex))
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<RejectSessionCookieWhenAccountNotInCacheEvents>>();
logger?.LogWarning("Token cache issue detected: {ErrorCode}",
ex.InnerException is MsalUiRequiredException msalEx ? msalEx.ErrorCode : "unknown");
context.RejectPrincipal();
await context.HttpContext.SignOutAsync();
}
catch (MsalServiceException ex) when (IsTokenCacheCollision(ex))
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<RejectSessionCookieWhenAccountNotInCacheEvents>>();
logger?.LogWarning("Multiple tokens in cache: {ErrorCode}", ex.ErrorCode);
context.RejectPrincipal();
await context.HttpContext.SignOutAsync();
}
catch (MsalClientException ex) when (IsTokenCacheCollision(ex))
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<RejectSessionCookieWhenAccountNotInCacheEvents>>();
logger?.LogWarning("Token cache collision: {Message}", ex.Message);
context.RejectPrincipal();
await context.HttpContext.SignOutAsync();
}
}
private static bool AccountDoesNotExitInTokenCache(MicrosoftIdentityWebChallengeUserException ex)
{
return ex.InnerException is MsalUiRequiredException { ErrorCode: "user_null" };
}
private static bool IsTokenCacheCollision(MsalException ex)
{
return ex.ErrorCode == "multiple_matching_tokens_detected" ||
ex.Message.Contains("multiple tokens", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("cache contains multiple", StringComparison.OrdinalIgnoreCase);
}
}
builder.Services.Configure<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme,
options =>
{
options.Events = new RejectSessionCookieWhenAccountNotInCacheEvents();
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.AccessDeniedPath = "/Account/AccessDenied";
});
builder.Services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("DatabaseConnectionString");
options.SchemaName = "dbo";
options.TableName = "TokenCache";
options.DefaultSlidingExpiration = TimeSpan.FromDays(14); // Refresh on access
options.ExpiredItemsDeletionInterval = TimeSpan.FromMinutes(30); // Auto-cleanup
});
// Configure AFTER .AddDistributedTokenCaches()
// Without pinning AbsoluteExpirationRelativeToNow, near-expiry tokens evict from L1
// almost immediately, causing a ~1.75s SQL distributed-cache read on every request.
builder.Services.Configure<MsalDistributedTokenCacheAdapterOptions>(options =>
{
options.DisableL1Cache = false;
options.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15);
});
Required using: Microsoft.Identity.Web.TokenCacheProviders.Distributed
Note:
AbsoluteExpirationRelativeToNow(inherited fromDistributedCacheEntryOptions) is the property that pins the L1 TTL. The adapter reads this in its constructor to set_memoryCacheExpirationTimefor all L1MemoryCacheEntryOptions.
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration)
.EnableTokenAcquisitionToCallDownstreamApi(allScopes)
.AddDistributedTokenCaches(); // Uses the configured distributed cache
MsalServiceException (server) and MsalClientException (local) separatelymultiple_matching_tokens_detected + message content checksSignOutAsync() inside ValidatePrincipal creates a redirect loop; call RejectPrincipal() onlyCREATE TABLE dbo.TokenCache
(
Id NVARCHAR(449) NOT NULL PRIMARY KEY,
Value VARBINARY(MAX) NOT NULL,
ExpiresAtTime DATETIMEOFFSET NOT NULL,
SlidingExpirationInSeconds BIGINT,
AbsoluteExpiration DATETIMEOFFSET
)
CREATE INDEX Index_ExpiresAtTime ON TokenCache (ExpiresAtTime)
SignOutAsync() in ValidatePrincipal: In this project, this creates an auth redirect loop — call context.RejectPrincipal() onlyL1ExpirationTimeSpan: Without this, near-expiry tokens cause L1 cache eviction immediately after write, forcing a ~1.75s SQL read on every requestMsalDistributedTokenCacheAdapterOptions requires using Microsoft.Identity.Web.TokenCacheProviders.DistributedGetService<ILogger<T>>() at request time, not constructor DI in cookie eventsuser_null: Cache collisions have different error codes/messages — must check multiple patterns