Type: Bug / Security
Environment
ABP Framework: 10.x Frontend: Angular 21 (OAuth2/OIDC via the ABP Angular packages) Authentication server: OpenIddict Token type: JWT access token Access token lifetime: 15 min
Description When a user logs out of the Angular application, the access token should be revoked so it can no longer be used. In our tests, a token copied before logout still works against the APIs after logout.
Steps to reproduce
Log in to the Angular application. Open the browser dev tools (Application → Local Storage) and copy the access_token value. Save the token somewhere outside the browser. Log out from the application. Call a protected API endpoint from Postman with the header Authorization: Bearer .
Expected result The API rejects the token with 401 Unauthorized, because logout revoked it.
Actual result The API accepts the token and returns data as if the user were still logged in.
Impact A token that is leaked or copied (through XSS, a shared machine, or a compromised device) stays usable until it expires, even after the user has explicitly logged out. This defeats the purpose of logout.
Questions for the ABP team
Is this the expected default behavior, for example because access tokens are self-contained JWTs validated locally by the API?
What is the recommended way to make logout invalidate the access token immediately? Options we are considering: Reference access tokens with token entry validation enabled in OpenIddict Calling the revocation endpoint from the Angular client on logout Another approach you recommend
Attachments (available on request): OpenIddict configuration, API logs, minimal reproduction project.
2 Answer(s)
-
0
Yes, this is expected by default when you use self-contained JWT access tokens with ABP (OpenIddict) and your APIs validate them locally (signature + exp, etc.).
Logging out from the Angular app typically:- ends the local session in the browser (clears tokens/storage), and/or
- calls the OpenID Connect end session endpoint
but it does not automatically invalidate already-issued JWT access tokens. A copied JWT will keep working until it expires unless you add server-side revocation/introspection checks.
Recommended way to make logout invalidate access immediately: switch to a model where the API can detect revocation server-side, e.g. token introspection / reference tokens (or otherwise force the API to consult a server-side store on each request).
- In ABP, one supported approach is to introspect access tokens, so the API doesn’t just accept any still-unexpired JWT purely by signature/exp. This is referenced in an ABP support answer where enabling introspection solved token/session mismatch behavior:
- enable something like
options.IntrospectAccessToken();in the API authentication configuration (exact placement depends on your host/API project setup).
This changes the validation behavior from “pure JWT validation” toward “ask the auth server / validate token entry”, enabling immediate invalidation when the token is revoked/removed.
- enable something like
- In ABP, one supported approach is to introspect access tokens, so the API doesn’t just accept any still-unexpired JWT purely by signature/exp. This is referenced in an ABP support answer where enabling introspection solved token/session mismatch behavior:
About your considered options:
- Reference access tokens + validation/introspection enabled: This is the standard way to get immediate revocation semantics (API can reject a token right after logout/revocation). This is typically the most robust option for your requirement.
- Calling the revocation endpoint on logout from Angular: This can help, but only if your APIs are doing revocation-aware validation (introspection/token store check). If the API continues to accept self-contained JWTs purely locally, revoking at the auth server won’t matter to the API.
- Short-lived access tokens (what you already have: 15 min): reduces exposure window but doesn’t give “immediate logout invalidation”.
Practical guidance (secure-by-design):
- If you need “logout means token is unusable immediately”, you must ensure the API validates tokens with a server-side check (introspection/reference tokens), not just offline JWT validation.
- If you also use refresh tokens, logout should revoke/clear them too (so the client can’t silently obtain a new access token).
Sources (validated)
- https://abp.io/support/questions/5071
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
The Angular logout already calls the revocation endpoint, and the AuthServer deletes the user's session. The APIs then rely on dynamic claims to notice that the session is gone. In the microservice solution this check has two problems.
- In each API service,
app.UseAuthorization()runs beforeapp.UseDynamicClaims(). A controller that relies only on[Authorize]has already passed authorization when dynamic claims clears the user, so it still returns data. Move the existingapp.UseAuthorization()call to right after the existingapp.UseDynamicClaims()call, and keep all the other middleware calls where they are:
app.UseDynamicClaims(); app.UseAuthorization();After this change, such a controller rejects the revoked token with 403, not 401.
- The API services other than the identity service cache the dynamic claims per user, not per session. Once the same user sends a request with another session (another browser, or after logging in again), that cache is filled again and the revoked token is accepted again as long as it hasn't expired. You can use this workaround for now: when a session is deleted, put its id into the distributed cache, and reject any token that carries that session id.
Add this handler to the AuthServer and the IdentityService (the apps that delete sessions):
using System; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Volo.Abp.Caching; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities.Events; using Volo.Abp.EventBus; using Volo.Abp.Identity; using Volo.Abp.MultiTenancy; namespace MyCompanyName.MyProjectName; public class RevokedIdentitySessionHandler : ILocalEventHandler<EntityDeletedEventData<IdentitySession>>, ITransientDependency { protected IDistributedCache<RevokedIdentitySessionCacheItem> Cache { get; } public RevokedIdentitySessionHandler(IDistributedCache<RevokedIdentitySessionCacheItem> cache) { Cache = cache; } public virtual async Task HandleEventAsync(EntityDeletedEventData<IdentitySession> eventData) { // Keep it longer than your maximum access token lifetime plus the clock skew. await Cache.SetAsync(eventData.Entity.SessionId, new RevokedIdentitySessionCacheItem(), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) }, hideErrors: false); } } [Serializable] [IgnoreMultiTenancy] [CacheName("RevokedIdentitySession")] public class RevokedIdentitySessionCacheItem { }Add this contributor to each of your other API services, with the same
RevokedIdentitySessionCacheItemclass:using System; using System.Linq; using System.Security.Claims; using System.Security.Principal; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Volo.Abp.Caching; using Volo.Abp.Security.Claims; namespace MyCompanyName.MyProjectName; public class RevokedIdentitySessionDynamicClaimsPrincipalContributor : AbpDynamicClaimsPrincipalContributorBase { public override async Task ContributeAsync(AbpClaimsPrincipalContributorContext context) { var sessionId = context.ClaimsPrincipal.Identities.FirstOrDefault()?.FindSessionId(); if (sessionId == null) { return; } var cache = context.GetRequiredService<IDistributedCache<RevokedIdentitySessionCacheItem>>(); try { if (await cache.GetAsync(sessionId, hideErrors: false) != null) { context.ClaimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); } } catch (Exception e) { context.GetRequiredService<ILogger<RevokedIdentitySessionDynamicClaimsPrincipalContributor>>() .LogError(e, "Could not check the revoked session cache."); context.ClaimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); } } }This needs dynamic claims enabled in these services (
IsDynamicClaimsEnabled = true, which the template sets), and all apps need to use the same Redis database and the sameAbpDistributedCacheOptions.KeyPrefix. It only covers sessions deleted after you deploy it. You don't need reference tokens or introspection for this workaround.We'll fix both in the framework and the microservice template.
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - In each API service,