Here is a bug that catches a lot of teams off guard the first time it happens. A user logs into an ASP.NET Core application secured with Microsoft Entra ID, everything works fine, and then someone restarts the application (a deployment, an app service recycle, whatever). The user’s browser still has a valid session cookie, so they never get redirected to login again. But the moment the application tries to call a downstream API on their behalf, it fails. The access token for that downstream call is simply gone.
This is not a security bug and not really a Microsoft Identity Web bug either. It is a direct consequence of using an in-memory token cache in a stateless world. Once you understand why it happens, the fix is a few lines of code. I will walk through the setup, why the tokens disappear, and how to handle it cleanly, along with the trade-offs of the other approaches available to you.
The setup: OpenID Connect with a downstream API
The application in question is a fairly standard secure ASP.NET Core app using OpenID Connect with PKCE against Microsoft Entra ID, implemented through the Microsoft.Identity.Web NuGet package. On top of the sign-in, the app also needs to call Microsoft Graph on behalf of the signed-in user. That is done using the On-Behalf-Of (OBO) flow, where the delegated access token from the user’s sign-in is exchanged for a Graph access token. The browser session itself is stored in a secure cookie, but the downstream API tokens are stored separately, in a token cache.

That separation is the whole story. The session cookie tells ASP.NET Core who the user is and that they are authenticated. The token cache is what actually holds the access and refresh tokens Microsoft.Identity.Web needs to call Graph or any other downstream API. These two things have completely different lifetimes unless you make a deliberate effort to keep them in sync.
Registering the client and enabling token acquisition for a downstream API looks like this in Program.cs.
var scopes = configuration.GetValue<string>("DownstreamApi:Scopes");
string[] initialScopes = scopes!.Split(' ');
services.AddMicrosoftIdentityWebAppAuthentication(configuration)
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph("https://graph.microsoft.com/v1.0", initialScopes)
.AddInMemoryTokenCaches();
Nothing unusual here. AddMicrosoftIdentityWebAppAuthentication wires up the OpenID Connect handshake, EnableTokenAcquisitionToCallDownstreamApi tells the library it needs to fetch extra tokens beyond the id_token, and AddMicrosoftGraph configures a typed Graph client using those tokens. The line that matters for this bug is the last one: AddInMemoryTokenCaches. That single call is the root cause of everything that follows, though it is also the simplest and fastest option to get running, which is exactly why most sample projects and a lot of production apps default to it.
Calling Microsoft Graph as a downstream API
With the client registered, calling Graph is straightforward. Microsoft.Identity.Web.GraphServiceClient (built on Graph SDK v5) can be injected directly into a service, and the library handles token acquisition behind the scenes using the OBO flow. You do not have to manually request a token before every call.
public class MsGraphService
{
private readonly GraphServiceClient _graphServiceClient;
private readonly string[] _scopes;
public MsGraphService(GraphServiceClient graphServiceClient,
IConfiguration configuration)
{
_graphServiceClient = graphServiceClient;
var scopes = configuration.GetValue<string>("DownstreamApi:Scopes");
_scopes = scopes!.Split(' ');
}
public async Task<User?> GetGraphApiUser()
{
return await _graphServiceClient.Me
.GetAsync(b => b.Options.WithScopes(_scopes));
}
}
This service reads the same scopes configured earlier and calls the Graph /me endpoint with them. When everything is in a healthy state, GetAsync silently triggers a cache lookup, finds a valid token (or refreshes it), and returns the user’s Graph profile. The problem only shows up once that cache lookup comes back empty.
Restart the application, keep the browser’s session cookie alive, and call GetGraphApiUser again. Because AddInMemoryTokenCaches keeps everything in the process’s memory, a restart wipes the cache clean. ASP.NET Core still thinks the user is authenticated because the cookie says so, but Microsoft.Identity.Web has no token on file for that user any more. The call to acquire a token throws a MsalUiRequiredException wrapped inside a MicrosoftIdentityWebChallengeUserException, with the error code user_null. Left unhandled, this bubbles up as an unhandled exception on a page that should have just worked.
The fix: reject the cookie when the cache has nothing
The correct fix is not to catch the exception and swallow it. That would leave the user looking at a broken page with no way forward. The right move is to detect this specific situation early, in the cookie validation pipeline, and force the user to sign in again so a fresh token gets acquired and cached. ASP.NET Core’s cookie authentication has a hook for exactly this, CookieAuthenticationEvents.ValidatePrincipal, which runs on every request that carries the authentication cookie.
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
namespace BffAzureAD.Server;
public class RejectSessionCookieWhenAccountNotInCacheEvents
: CookieAuthenticationEvents
{
private readonly string[] _downstreamScopes;
public RejectSessionCookieWhenAccountNotInCacheEvents(string[] downstreamScopes)
{
_downstreamScopes = downstreamScopes;
}
public async override Task ValidatePrincipal(
CookieValidatePrincipalContext context)
{
try
{
var tokenAcquisition = context.HttpContext.RequestServices
.GetRequiredService<ITokenAcquisition>();
string token = await tokenAcquisition.GetAccessTokenForUserAsync(
scopes: _downstreamScopes, user: context.Principal);
}
catch (MicrosoftIdentityWebChallengeUserException ex)
when (AccountDoesNotExitInTokenCache(ex))
{
context.RejectPrincipal();
}
}
private static bool AccountDoesNotExitInTokenCache(
MicrosoftIdentityWebChallengeUserException ex)
{
return ex.InnerException is MsalUiRequiredException
&& (ex.InnerException as MsalUiRequiredException)!.ErrorCode
== "user_null";
}
}
This class runs a token acquisition check on every request, before any controller or page code executes. If GetAccessTokenForUserAsync throws the specific user_null error, that is the signal that this cookie belongs to a user whose token was lost from the cache. The handler calls context.RejectPrincipal(), which invalidates the cookie for that request and forces the OpenID Connect middleware to redirect to the identity provider for a fresh sign-in. Any other exception is left to propagate normally, since you do not want to silently reject cookies for unrelated failures such as a network blip talking to Entra ID.
One thing worth calling out: this check runs on every authenticated request, since ValidatePrincipal fires for every incoming request carrying the cookie. That means an extra token acquisition attempt (a cache lookup, not a network call, as long as the token is present and not expired) on every page load. For most line-of-business apps this overhead is negligible, but if you are running a high-traffic public-facing app, measure it before assuming it is free.
Wiring this handler in is a one-line addition to your existing authentication configuration.
// If using downstream APIs and in memory cache, you need to reset the cookie session if the cache is missing
// If you use persistent cache, you do not require this.
// You can also return the 403 with the required scopes, this needs special handling for ajax calls
// The check is only for single scopes
services.Configure<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme,
options => options.Events = new RejectSessionCookieWhenAccountNotInCacheEvents(initialScopes));
After this is in place, restarting the app no longer produces a broken session for existing users. The next request they make gets silently redirected through the OpenID Connect login flow, a new token gets acquired, cached, and the user lands back where they were, usually without even noticing anything happened. Worth noting in the comment on this snippet: the check as written only handles a single set of scopes. If your app calls multiple downstream APIs with different scope sets, each one needs its own check, or you need to loop over all the scope sets your app depends on.
Why not just catch the exception and move on
A common shortcut is to wrap the Graph or downstream API call in a try/catch and return a null or an empty result when the token is missing. This avoids the crash, but it leaves the user in a confusing half-authenticated state. They appear logged in, some parts of the UI work, and the parts depending on downstream data quietly fail or show empty screens with no indication why. Rejecting the cookie and forcing re-authentication is more honest to the user and cheaper to support, since a support ticket that says ‘the page was blank’ is harder to diagnose than one where the user simply had to log in again.
Alternative solutions and their trade-offs
Rejecting the cookie is not the only way to solve this, and it is not always the best one for every deployment. It is worth weighing the alternatives against your own constraints.
- Use a persistent token cache (Redis, Cosmos DB, SQL Server) instead of AddInMemoryTokenCaches. Tokens survive app restarts and scale-outs across multiple instances, since the cache lives outside the process. The trade-off is an extra infrastructure dependency and a small amount of added latency on every token read and write.
- Avoid downstream APIs entirely where possible, and rely only on the id_token claims already present in the session. This sidesteps the whole class of problem but only works if you genuinely do not need to call Graph or any other API on the user’s behalf.
- Return an HTTP 403 with the missing scopes and let the front end trigger a step-up re-authentication for just those scopes. This keeps the existing session alive for everything else, but it needs careful handling on the client, especially for AJAX or SPA calls where a silent redirect is not straightforward to pull off.
For most teams running a single-instance or low-scale deployment, in-memory cache plus the cookie-rejection handler shown above is a reasonable default: no extra infrastructure, and the failure mode is just an extra login prompt. Once you scale out to multiple instances behind a load balancer, in-memory cache stops making sense regardless of this fix, because a token cached on one instance is invisible to the others. At that point a persistent cache is not optional, it is a requirement, and this whole workaround becomes unnecessary.
If you are deciding between these options at the design stage rather than debugging this in production, my advice is to just start with a persistent cache (Redis is the common choice on Azure) unless you have a specific reason not to. The added complexity is small, and it removes an entire category of restart-related bugs before they happen.
Leave a Reply