Featured image of post Keycloak JWT Token Revocation: Why Logout Does Not Always Return 401

Keycloak JWT Token Revocation: Why Logout Does Not Always Return 401

Understand why a revoked Keycloak access token can remain usable until expiration, and compare local JWT validation with strict per-request introspection in Angular and .NET.

When a user clicks Log out in a frontend application, it is natural to expect the next API request made with the old access token to return 401 Unauthorized. That expectation is only correct when the API checks the authorization server for the token’s current state.

A signed JWT is normally validated locally. The API checks its signature, issuer, claims, and expiration time. It does not automatically call Keycloak after every request. Therefore, a JWT issued for five minutes can remain usable for those five minutes even after the browser logs out or Keycloak receives a revocation request.

This post demonstrates both behaviors with an Angular, Keycloak, and .NET 10 products catalog:

  • local JWT validation, which is fast and accepts a valid token until expiration
  • strict JWT validation with Keycloak introspection, which detects revocation on the next API call

The sample uses Authorization Code Flow with PKCE. The frontend runs on http://localhost:3000, the API on http://localhost:5001, and Keycloak on http://localhost:8080.

The Short Answer

There are three related operations:

  1. Frontend logout clears the application’s local session and redirects the browser.
  2. OIDC logout ends the user’s Keycloak browser session.
  3. Token revocation asks Keycloak to invalidate a token according to its revocation support.

None of these operations edits the JWT already issued to the client. The token still contains the same signed claims and expiration timestamp.

The resource server decides how to validate it:

text
1
2
3
4
5
6
7
Local JWT validation:
  verify signature + issuer + claims + exp
  no live Keycloak request

JWT validation + introspection:
  verify JWT locally
  ask Keycloak whether active == true

That is why logout can succeed in the browser while a copied token still works against an API using local JWT validation.

What Is Inside a JWT?

A JWT contains three Base64URL-encoded parts:

text
1
header.payload.signature

Its payload might contain:

json
1
2
3
4
5
6
7
{
  "iss": "http://localhost:8080/realms/demo",
  "sub": "user-id",
  "aud": "api-introspection",
  "iat": 1788080000,
  "exp": 1788080300
}

The exp claim is important. The API can prove that Keycloak signed the token and that it has not expired. It cannot discover a later revocation event from the token itself because the token is immutable.

A useful distinction is:

text
1
2
JWT validation answers: "Was this token correctly issued and is it within its lifetime?"
Introspection answers:   "Does Keycloak currently consider this token active?"

These are different questions.

What Revocation Actually Revokes

Keycloak’s revocation endpoint supports both access tokens and refresh tokens:

text
1
POST /realms/{realm}/protocol/openid-connect/revoke

Revoking each token has a different effect:

  • Access-token revocation marks that token as revoked at Keycloak. An API using local JWT validation still cannot see that change; it can continue accepting the signed JWT until exp.
  • Refresh-token revocation prevents the client from using that refresh token to obtain another access token. It does not rewrite an access JWT that the client already received.
  • OIDC logout ends the browser session and asks Keycloak to end the client session. It is not a live invalidation mechanism for APIs that only validate JWTs locally.

Therefore, a complete client logout should discard its local access token and refresh token, revoke the refresh token when the client can do so, and end the OIDC session. A resource server that needs immediate rejection must use introspection, a revocation list, or another server-side policy.

The revocation endpoint normally returns success without exposing whether the submitted token was already valid. Treat the token as unusable after a successful request and never log its value.

Keycloak Configuration

The imported realm configures a five-minute access-token lifetime:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "realm": "demo",
  "accessTokenLifespan": 300,
  "clients": [
    {
      "clientId": "code-flow-client",
      "publicClient": true,
      "standardFlowEnabled": true
    },
    {
      "clientId": "api-introspection",
      "publicClient": false,
      "serviceAccountsEnabled": true
    }
  ]
}

The Angular client is public because a browser application cannot safely store a client secret. PKCE protects the authorization-code exchange. The backend introspection client is confidential and owns the secret used to call Keycloak.

In Keycloak Admin Console, the useful screens are:

  • Realm settings -> Tokens: access-token lifespan is five minutes.
  • Clients -> code-flow-client -> Capability config: Standard Flow enabled and Implicit Flow disabled.
  • Clients -> api-introspection -> Credentials: confidential client secret used by the API.

Make sure the Admin Console is using the demo realm before opening Clients. The master realm contains Keycloak’s built-in clients and will not show code-flow-client or api-introspection. The sample imports both clients from keycloak/realm-export.json into demo when Keycloak starts.

If demo exists but the clients are still missing, the database volume may have been initialized before the realm export was added. Realm import is not re-applied to an existing realm; import the file manually or recreate the local Keycloak database volume, then select demo in the Admin Console.

The imported realm contains these settings:

code-flow-client settings

Configure this client as a browser-only public client:

Setting Value
Client ID code-flow-client
Client authentication Off
Authentication flow Standard flow enabled; Implicit flow disabled
Valid redirect URIs http://localhost:3000/*
Web origins http://localhost:3000
PKCE S256, configured by the Angular client

The important point is Client authentication off. The Angular application cannot keep a client secret safe in browser code. Standard Flow uses the authorization-code flow, and PKCE protects the code exchange.

Keycloak code-flow-client settings

Keycloak code-flow-client access settings

Keycloak code-flow-client capability settings

api-introspection settings

Configure this client as a confidential backend client:

Setting Value
Client ID api-introspection
Client authentication On
Client secret api-introspection-demo-secret for this local sample
Standard flow Disabled
Direct access grants Disabled
Service accounts Enabled

The API uses this client’s secret with HTTP Basic authentication when it calls Keycloak’s token introspection endpoint. Never put this secret in Angular code or expose it to a browser.

Keycloak api-introspection settings and credentials

Keycloak api-introspection access settings

Open Clients -> code-flow-client and Clients -> api-introspection in the demo realm to compare these screens with the imported configuration. The api-introspection screenshot shows the client settings and the Credentials tab where the backend secret is managed.

The sample uses sslRequired: NONE for local development. Production deployments must use HTTPS and secret storage.

Local Demo Credentials

Use these credentials only for the local sample:

Purpose Username Password or token
Angular application demo demo-password
Keycloak Admin Console admin admin

The Angular credentials are defined in keycloak/realm-export.json. The admin credentials are supplied by Aspire when it starts the Keycloak container. Do not reuse either password outside local development.

The Aspire Dashboard does not use a fixed username and password in this sample. When you run the AppHost, Aspire prints a one-time login URL containing a token:

text
1
Login to the dashboard at https://localhost:<dashboard-port>/login?t=<generated-token>

Open that URL from the AppHost output to access the dashboard. The dashboard port and token can change each time the AppHost starts, so do not save the displayed token in the article or source code.

Angular Login and Logout

The frontend uses Authorization Code Flow with S256 PKCE:

typescript
1
2
3
4
5
6
await this.client.init({
  onLoad: 'login-required',
  flow: 'standard',
  pkceMethod: 'S256',
  checkLoginIframe: false,
});

The sample logout method revokes the access token and then ends the browser session:

typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public async logout(): Promise<void> {
  const token = this.client?.token;
  const settings = this.config.value;

  if (token) {
    const response = await fetch(
      `${settings.keycloakUrl}/realms/${settings.keycloakRealm}` +
      `/protocol/openid-connect/revoke`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          client_id: settings.keycloakClientId,
          token,
          token_type_hint: 'access_token',
        }),
        keepalive: true,
      });

    if (!response.ok) {
      throw new Error(`Token revocation failed: ${response.status}`);
    }
  }

  await this.client?.logout({ redirectUri: window.location.origin });
}

This sample uses a public Angular client, so it sends client_id without a client secret. It currently revokes the access token explicitly. If the application also keeps a refresh token, revoke that token before clearing it:

bash
1
2
3
4
5
6
curl --location --request POST \
  'http://localhost:8080/realms/demo/protocol/openid-connect/revoke' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'client_id=code-flow-client' \
  --data-urlencode 'token=REFRESH_TOKEN' \
  --data-urlencode 'token_type_hint=refresh_token'

For a confidential client such as api-introspection, authenticate the revocation request with that client’s credentials instead of exposing a secret in browser code:

bash
1
2
3
4
5
6
curl --location --request POST \
  'http://localhost:8080/realms/demo/protocol/openid-connect/revoke' \
  --user 'api-introspection:CLIENT_SECRET' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'token=REFRESH_TOKEN' \
  --data-urlencode 'token_type_hint=refresh_token'

This protects the normal browser session. The browser no longer uses the token, and the Keycloak session ends. It does not force every API to perform a new authorization-server check. A copied token can still be replayed by Postman, a test script, or another process.

Before logout, the Angular home page can call the protected endpoint successfully:

Angular home page before logout

After logout, the Angular application clears its local session and redirects to the Keycloak sign-in page:

Angular home page after logout

Scenario One: Local JWT Validation

The sample also contains a local-validation extension, but it is not the default. The default backend configuration uses JWT validation followed by Keycloak introspection:

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public static void AddJwtAuthentication(this WebApplicationBuilder builder)
{
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.Authority = builder.Configuration["Keycloak:Authority"]
                ?? throw new InvalidOperationException(
                    "Missing configuration 'Keycloak:Authority'.");
            options.RequireHttpsMetadata = false;
            options.TokenValidationParameters.ValidateAudience = false;
        });
}

The API downloads Keycloak’s signing keys and validates JWTs locally. It checks the signature, issuer, lifetime, and configured claims. It does not call the revoke endpoint or introspection for each product request.

To use local JWT validation without introspection, register this mode with:

csharp
1
builder.AddJwtAuthentication();

Reproduce the behavior

  1. Sign in to the Angular app as demo with password demo-password.
  2. Open the products page and confirm GET /api/v1/catalogs/products returns 200 OK.
  3. Copy the access token from the browser network request.
  4. Click Log out.
  5. Replay the copied token with Postman or curl.
bash
1
2
curl --location 'http://localhost:5001/api/v1/catalogs/products' \
  --header 'Authorization: Bearer ACCESS_TOKEN'

With local validation, the request can still return the four products while exp has not passed. After the five-minute lifetime ends, the same request returns 401 Unauthorized.

This is expected. The API is verifying the token’s signed facts, not querying Keycloak’s current revocation state.

Scenario Two: Strict Revocation With Introspection

Some systems require a revoked token to stop working immediately. Examples include administration APIs, financial operations, sensitive data access, and security demonstrations.

The strict extension keeps local JWT validation and adds a Keycloak introspection call after it succeeds:

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void AddJwtAuthenticationWithIntrospection(
    this WebApplicationBuilder builder)
{
    builder.Services.AddHttpClient("keycloak-introspection", client =>
    {
        client.Timeout = TimeSpan.FromSeconds(5);
    });

    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.Authority = builder.Configuration["Keycloak:Authority"]
                ?? throw new InvalidOperationException(
                    "Missing configuration 'Keycloak:Authority'.");
            options.RequireHttpsMetadata = false;
            options.TokenValidationParameters.ValidateAudience = false;
            options.Events = new JwtBearerEvents
            {
                OnTokenValidated = ValidateTokenWithKeycloakAsync
            };
        });
}

The event sends the bearer token to:

text
1
/realms/{realm}/protocol/openid-connect/token/introspect

Keycloak returns an introspection document. The important field is active:

json
1
2
3
{
  "active": false
}

The API rejects the request when the token is inactive or revoked:

csharp
1
2
3
4
5
6
7
8
using var document = JsonDocument.Parse(
    await response.Content.ReadAsStringAsync());

if (!document.RootElement.TryGetProperty("active", out var active) ||
    !active.GetBoolean())
{
    context.Fail("Token is inactive or revoked");
}

The sample’s default host uses this behavior:

csharp
1
builder.AddJwtAuthenticationWithIntrospection();

Reproduce immediate rejection

  1. Start the API with the default configuration.
  2. Sign in and call the catalog endpoint with the access token.
  3. Confirm the first request returns 200 OK.
  4. Click Log out, which revokes the access token.
  5. Replay the exact same bearer token.
bash
1
2
3
curl --location --include \
  'http://localhost:5001/api/v1/catalogs/products' \
  --header 'Authorization: Bearer ACCESS_TOKEN'

The API validates the JWT locally, then asks Keycloak for current state. Keycloak reports active: false, so ASP.NET Core authentication fails:

http
1
HTTP/1.1 401 Unauthorized

The same result is visible when replaying the old token from Postman or curl:

Postman request returning 401 after token revocation

http
1
2
3
4
GET http://localhost:5001/api/v1/catalogs/products
Authorization: Bearer ACCESS_TOKEN

HTTP/1.1 401 Unauthorized

The JWT did not change. The second validation step consulted server-side state and rejected it.

sequenceDiagram participant Browser as Angular app participant KC as Keycloak participant API as Products API participant CLI as Postman or curl Browser->>KC: Revoke token and logout KC-->>Browser: Session ended CLI->>API: Replay old bearer token API->>KC: Introspect token KC-->>API: active = false API-->>CLI: 401 Unauthorized

Test With curl

Use curl to send the same bearer token before and after logout:

bash
1
2
3
4
5
ACCESS_TOKEN='paste-the-access-token-here'

curl --location --include \
  'http://localhost:5001/api/v1/catalogs/products' \
  --header "Authorization: Bearer ${ACCESS_TOKEN}"

Before logout, both authentication modes return 200 OK. After logout, local JWT validation can still return 200 OK until exp, while strict introspection returns 401 Unauthorized immediately.

Change only the backend registration between runs:

csharp
1
2
3
4
5
// Local JWT validation.
builder.AddJwtAuthentication();

// JWT validation plus Keycloak introspection.
builder.AddJwtAuthenticationWithIntrospection();

Why Logout Does Not Change a Local JWT Decision

The frontend, Keycloak, and the API are separate participants:

sequenceDiagram participant Browser as Angular browser participant Keycloak participant API as Products API Browser->>Keycloak: Login with code + PKCE Keycloak-->>Browser: Access JWT Browser->>API: GET products + JWT API->>API: Verify signature and exp API-->>Browser: 200 OK Browser->>Keycloak: Revoke token and logout Keycloak-->>Browser: Revoked/session ended Browser->>API: Replay old JWT alt Local JWT validation API->>API: Signature and exp still valid API-->>Browser: 200 OK until exp else Introspection enabled API->>Keycloak: Is token active? Keycloak-->>API: active = false API-->>Browser: 401 Unauthorized end

The browser controls the user interface session. Keycloak controls its session and revocation state. The API controls whether it performs only local validation or also checks current server-side state.

Why Not Introspection Everywhere?

Introspection gives immediate revocation, but it changes the performance and availability profile of the API.

Local validation:

  • signature verification is fast
  • no Keycloak round trip for each request
  • Keycloak is not required for every API call
  • a revoked token may remain accepted until expiration

Introspection:

  • revocation is detected on the next request
  • every authenticated request adds network latency
  • Keycloak becomes a live dependency of the API
  • Keycloak outages can affect otherwise valid API requests
  • the API must protect the confidential introspection secret

IHttpClientFactory manages HTTP handlers and sockets, but it does not cache introspection responses. The strict implementation still makes one Keycloak request per authenticated API request.

For most APIs, short-lived access tokens, refresh-token rotation, and local JWT validation are a good baseline. Use introspection when immediate revocation is a hard requirement.

Handling 401 in the Frontend

The frontend can react to an API 401 and clear its local session:

typescript
1
2
3
if (response.status === 401) {
  await this.keycloak.logout({ redirectUri: window.location.origin });
}

This improves the user experience, but it is not the security control. A caller can bypass Angular and send a bearer token directly to the API. The API must enforce authentication independently.

Security Notes

The sample is intentionally local and uses demo credentials. Production systems should also:

  • use HTTPS for Angular, the API, and Keycloak
  • keep confidential client secrets out of browser code
  • store introspection secrets in a secret manager
  • validate issuer, audience, signature, lifetime, and required claims
  • keep access tokens short-lived
  • rotate refresh tokens and protect refresh-token storage
  • restrict CORS to known frontend origins
  • define timeout and outage behavior for Keycloak
  • never log complete bearer tokens

The sample disables audience validation to keep the demonstration focused. A production API should validate its expected audience explicitly.

Conclusion

A JWT does not receive a live update when a user logs out. It is a signed, self-contained credential with an expiration time. Local JWT validation can therefore continue accepting it until exp, even after the frontend clears its session and Keycloak processes revocation.

Immediate 401 Unauthorized behavior requires an additional server-side check. In this sample:

csharp
1
2
3
4
5
// Optional fast path: token is accepted while locally valid.
builder.AddJwtAuthentication();

// Default sample configuration: revoked token is rejected on the next API call.
builder.AddJwtAuthenticationWithIntrospection();

Choose local JWT validation when scalability and Keycloak independence matter most. Choose introspection when immediate revocation is more important than the extra latency and dependency on Keycloak.

You can find the sample code in this repository:

Reference

Built with Hugo
Theme Stack designed by Jimmy