Why Does My Tiered ABP App Show an Empty Menu While the User Is Still Signed In?
TL;DR: In a tiered MVC application, ABP falls back to an identity-client token when it cannot read the signed-in user's access token, so the API can return a valid but user-less response with an empty
grantedPoliciesdictionary. Replace the remote-service authenticator so an authenticated user can never take that fallback, then force a fresh OIDC challenge when a page request has a user cookie but noaccess_token.
The Symptom
The user is visibly signed in, but the application behaves as if they have no permissions:
- The navigation menu is almost empty.
- Permission-guarded buttons and partials disappear.
- An order or product grid returns no rows.
/api/abp/application-configurationcontains an emptyauth.grantedPoliciesdictionary.- Signing out and back in fixes the session.
There may be no exception, 401, or 403. The web tier gets a successful response and renders it.
We initially treated this as a permission-cache problem. Logging the granted-policy count proved that it really dropped to zero, but not why. Redis remained healthy during the affected requests. We also lost time on the simplest explanation: a role with no grants produces the same UI. Check the role's permission grants before investigating the framework.
The useful comparison was the claims principal at both ends of one correlated request. The web tier had a user principal. The API tier received a valid client principal with no user subject. That moved the investigation from permission resolution to the outbound HTTP call.
Do not use token length as the test. Decode claims without logging the raw token. A user token normally has a user subject (sub), while a client-credentials token represents the application.
Why It Happens
A tiered ABP MVC UI calls the API through dynamic C# client proxies. Before a protected proxy request is sent, ABP resolves IRemoteServiceHttpClientAuthenticator.
For an MVC or Razor Pages host that depends on AbpHttpClientIdentityModelWebModule, the implementation is HttpContextIdentityModelRemoteServiceHttpClientAuthenticator. Its ABP 10.5.0 sequence is:
- Unless
RemoteServices:<name>:UseCurrentAccessTokenisfalse, callIAbpAccessTokenProvider.GetTokenAsync(). - If that returns a token, put it on the outgoing request and return.
- If it returns
null, call the base authenticator.
The base IdentityModelRemoteServiceHttpClientAuthenticator calls IIdentityModelAuthenticationService.TryAuthenticateAsync. It selects the remote service's IdentityClient, then the remote-service name, with ABP's identity-client configuration ultimately able to fall back to Default. The official IdentityModel Clients documentation describes that path as server-to-server authentication.
That fallback is intentional when no user exists:
page request
-> current user's access token exists
-> API receives user identity
background or app-to-app request
-> no current user token exists
-> IdentityClients supplies an application token
broken user session
-> user cookie still authenticates
-> access_token is missing
-> IdentityClients supplies an application token
-> API receives the application identity
The third branch is the trap. If the client has permission to call the endpoint but no user permissions, the HTTP request can succeed while the response contains no user-specific grants. A successful wrong-identity response is harder to diagnose than a failed request.
ASP.NET Core only stores remote access and refresh tokens in AuthenticationProperties when SaveTokens is enabled; it defaults to false to limit cookie size. ABP's tiered MVC template sets it to true. The framework's HttpContextAbpAccessTokenProvider then uses HttpContext.GetTokenAsync("access_token").
Why that token went missing is a separate investigation. Cookie size, a server-side ticket store, Data Protection configuration, and token-refresh handling are all possible places to look. The fix below does not repair token storage; it prevents a missing token from silently changing the caller's identity.
The Fix
We made the unsafe state explicit: client credentials remain valid when there is no authenticated user, but not when an authenticated user has lost their access token.
Add this class to the tiered web host:
using System.Threading.Tasks;
using Duende.IdentityModel.Client;
using Microsoft.Extensions.Logging;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Http.Client;
using Volo.Abp.Http.Client.Authentication;
using Volo.Abp.Http.Client.IdentityModel.Web;
using Volo.Abp.IdentityModel;
using Volo.Abp.Users;
namespace Acme.Orders.Web;
[Dependency(ReplaceServices = true)]
[ExposeServices(
typeof(IRemoteServiceHttpClientAuthenticator),
typeof(HttpContextIdentityModelRemoteServiceHttpClientAuthenticator))]
public class StrictRemoteServiceHttpClientAuthenticator
: HttpContextIdentityModelRemoteServiceHttpClientAuthenticator
{
private readonly ICurrentUser _currentUser;
private readonly ILogger<StrictRemoteServiceHttpClientAuthenticator> _logger;
public StrictRemoteServiceHttpClientAuthenticator(
IIdentityModelAuthenticationService identityModelAuthenticationService,
IAbpAccessTokenProvider accessTokenProvider,
ICurrentUser currentUser,
ILogger<StrictRemoteServiceHttpClientAuthenticator> logger)
: base(identityModelAuthenticationService, accessTokenProvider)
{
_currentUser = currentUser;
_logger = logger;
}
public override async Task Authenticate(
RemoteServiceHttpClientAuthenticateContext context)
{
if (context.RemoteService.GetUseCurrentAccessToken() != false)
{
var accessToken = await AccessTokenProvider.GetTokenAsync();
if (accessToken is not null)
{
context.Request.SetBearerToken(accessToken);
return;
}
if (_currentUser.IsAuthenticated)
{
_logger.LogError(
"Blocked identity-client fallback for authenticated user {UserId}. " +
"RemoteService={RemoteService}, Path={Path}",
_currentUser.Id,
context.RemoteServiceName,
context.Request.RequestUri?.AbsolutePath);
// Send no bearer token. A protected API now answers 401 instead
// of successfully running under the application's identity.
return;
}
}
// No current user, or UseCurrentAccessToken is explicitly false:
// preserve ABP's app-to-app identity-client behavior.
await base.Authenticate(context);
}
}
[Dependency(ReplaceServices = true)] replaces existing descriptors, while [ExposeServices] makes the two resolution surfaces explicit. The interface is what ABP's client proxy resolves. Exposing the framework concrete type as well prevents application code that injects that type from bypassing the replacement.
Failing loudly protects the API call, but a user should not remain in a broken session. Add page-only middleware that signs out the cookie and uses the application's configured default challenge scheme:
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Logging;
namespace Acme.Orders.Web;
public sealed class EnsureAccessTokenMiddleware
{
private const string RecoveryCookie = ".Orders.AccessTokenRecovery";
private static readonly PathString[] ExcludedPrefixes =
[
"/api",
"/Abp",
"/connect",
"/hangfire",
"/health",
"/signalr",
"/swagger",
"/_"
];
private readonly RequestDelegate _next;
private readonly ILogger<EnsureAccessTokenMiddleware> _logger;
public EnsureAccessTokenMiddleware(
RequestDelegate next,
ILogger<EnsureAccessTokenMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity?.IsAuthenticated == true &&
IsPageRequest(context))
{
var accessToken = await context.GetTokenAsync("access_token");
if (string.IsNullOrEmpty(accessToken))
{
if (context.Request.Cookies.ContainsKey(RecoveryCookie))
{
context.Response.Cookies.Delete(RecoveryCookie);
context.Response.StatusCode =
StatusCodes.Status500InternalServerError;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(
"The sign-in session could not be recovered.");
return;
}
_logger.LogWarning(
"Authenticated page request has no access_token. Path={Path}",
context.Request.Path);
context.Response.Cookies.Append(
RecoveryCookie,
"1",
new CookieOptions
{
HttpOnly = true,
Secure = context.Request.IsHttps,
SameSite = SameSiteMode.Lax,
IsEssential = true,
MaxAge = TimeSpan.FromMinutes(2)
});
var returnUrl = context.Request.GetEncodedPathAndQuery();
await context.SignOutAsync();
await context.ChallengeAsync(
new AuthenticationProperties { RedirectUri = returnUrl });
return;
}
if (context.Request.Cookies.ContainsKey(RecoveryCookie))
{
context.Response.Cookies.Delete(RecoveryCookie);
}
}
await _next(context);
}
private static bool IsPageRequest(HttpContext context)
{
if (!HttpMethods.IsGet(context.Request.Method) ||
!context.Request.Headers.Accept.Any(
value => value?.Contains(
"text/html",
StringComparison.OrdinalIgnoreCase) == true))
{
return false;
}
var path = context.Request.Path;
if (ExcludedPrefixes.Any(prefix => path.StartsWithSegments(prefix)))
{
return false;
}
return string.IsNullOrEmpty(Path.GetExtension(path.Value));
}
}
Use the default schemes rather than hard-coding them. The ABP 10.5 tiered MVC template registers "Cookies" as DefaultScheme and "oidc" as DefaultChallengeScheme; OpenIdConnectDefaults.AuthenticationScheme has a different value.
The web module must already depend on AbpHttpClientIdentityModelWebModule. Register the middleware after authentication populates HttpContext.User and before authorization consumes it:
using Microsoft.AspNetCore.Builder;
using Volo.Abp;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.Http.Client.IdentityModel.Web;
using Volo.Abp.Modularity;
namespace Acme.Orders.Web;
[DependsOn(
typeof(AbpAspNetCoreMvcModule),
typeof(AbpHttpClientIdentityModelWebModule))]
public class OrdersWebModule : AbpModule
{
public override void OnApplicationInitialization(
ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
// Keep the rest of your generated ABP pipeline in its existing order.
app.UseRouting();
app.UseAuthentication();
app.UseMiddleware<EnsureAccessTokenMiddleware>();
// Keep UseMultiTenancy() and UseDynamicClaims() here when enabled.
app.UseAuthorization();
app.UseConfiguredEndpoints();
}
}
In an existing generated module, add only the UseMiddleware line at the shown location; do not remove its localization, correlation, static-asset, multi-tenancy, dynamic-claims, Swagger, logging, or endpoint configuration.
Gotchas
- Do not disable identity-client authentication globally. Background workers and app-to-app calls have no current user and legitimately need it.
UseCurrentAccessToken = falsemust also retain its documented meaning. - Middleware order is load-bearing. Before
UseAuthentication, there is no populated user to inspect. AfterUseAuthorization, the broken principal may already have produced an authorization result. - Restrict the challenge to browser page navigations. Challenging API, SignalR, health, Swagger, dashboard, framework, or static-file requests turns a clear authentication failure into redirects in places that cannot handle them. Extend the exclusions for your routes.
- Keep a loop guard. If the OIDC round-trip returns another token-less ticket, repeatedly challenging hides the underlying fault and can overload the auth server.
- Do not log access tokens, cookie values, client secrets, or full claims. Log the subject type, selected claim names, remote-service name, path, and correlation ID.
- This behavior is unchanged in the 10.6.0 source. ABP 10.6 does change
HttpContextAbpAccessTokenProviderto testHttpContext.User.Identity.IsAuthenticateddirectly. A separate ABP 10 issue affects forwarding an incoming client principal's bearer token; that produces a downstream401and is not this missing-user-token failure.
How to Verify It Worked
First verify the diagnosis without changing production state:
- Correlate one web request with its API request.
- On the web tier, record whether
ICurrentUser.IsAuthenticatedis true and whetherGetTokenAsync("access_token")returned a value. Do not record the value. - On the API tier, classify the principal as user, client, or anonymous from claims. Alert when a user-facing endpoint receives a client principal.
Then reproduce in a non-production tiered application. Sign in, re-issue the local authentication ticket without the access_token in its AuthenticationProperties, and request a permission-guarded page.
Before the change, the API receives a client token when an IdentityClients entry is configured, and the UI can render an empty permission map. After the change, the page middleware initiates one OIDC round-trip. If recovery is bypassed, the strict authenticator sends no bearer token and the protected API returns 401; it never receives the application's client identity for that user request.
Add a counter to the "Blocked identity-client fallback" event. Its healthy steady-state value is zero. A non-zero value means the guard worked and the separate token-storage investigation still has work to do.
Finally, keep a regression test around the decision table:
| Current user | Current token | Expected result |
|---|---|---|
| Authenticated | Present | Send the user token |
| Authenticated | Missing | Do not acquire an identity-client token |
| Not authenticated | Missing | Preserve identity-client authentication |
| Any | UseCurrentAccessToken = false |
Preserve identity-client authentication |
Scope and Caveats
This is for server-rendered tiered MVC or Razor Pages hosts using Volo.Abp.Http.Client.IdentityModel.Web. Blazor WebAssembly has a different authenticator and token-provider model.
The guard assumes user-facing API endpoints require authentication. An anonymous endpoint can still accept the no-token request, so endpoints whose output is user-specific must remain protected.
Most importantly, this is containment, not root-cause repair. Once the silent fallback is blocked, investigate why an authenticated ticket can exist without its access token. Check SaveTokens, cookie or ticket-store persistence, token refresh, Data Protection consistency across instances, and deployment timing. At larger scale, prefer a durable server-side ticket store over growing authentication cookies, but treat the store as security-sensitive infrastructure.
References
- ABP dynamic C# client proxies
- ABP IdentityModel Clients
- ABP 10.5.0
HttpContextIdentityModelRemoteServiceHttpClientAuthenticatorsource - ABP 10.5.0
IdentityModelRemoteServiceHttpClientAuthenticatorsource - ABP support explanation of MVC/Razor remote-service authentication
- ABP support: a different cause of empty application configuration
- ABP 10 token-forwarding regression for client principals
- Microsoft:
RemoteAuthenticationOptions.SaveTokens - Microsoft:
GetTokenAsync - Microsoft: ASP.NET Core authentication overview
About the Author
Kori Francis
CTO at Clinical Support Systems, working on production .NET and ABP Framework systems.
Comments
No one has commented yet, be the first to comment!