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:

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:

1
header.payload.signature

Its payload might contain:

1
2
3
4
5
6
7
{
  "iss": "http://localhost:8080/realms/implicit-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:

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:

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "realm": "implicit-demo",
  "accessTokenLifespan": 300,
  "clients": [
    {
      "clientId": "implicit-client",
      "publicClient": true,
      "standardFlowEnabled": true,
      "implicitFlowEnabled": false
    },
    {
      "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 -> implicit-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 implicit-demo realm before opening Clients. The master realm contains Keycloak’s built-in clients and will not show implicit-client or api-introspection. The sample imports both clients from keycloak/realm-export.json into implicit-demo when Keycloak starts.

If implicit-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 implicit-demo in the Admin Console.

The imported realm contains these settings:

Keycloak realm token settings

Keycloak Angular client capability settings

Keycloak introspection client credentials

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

Angular Login and Logout

The frontend uses Authorization Code Flow with S256 PKCE:

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:

 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:

1
2
3
4
5
6
curl --location --request POST \
  'http://localhost:8080/realms/implicit-demo/protocol/openid-connect/revoke' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'client_id=implicit-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:

1
2
3
4
5
6
curl --location --request POST \
  'http://localhost:8080/realms/implicit-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

This is the default high-throughput design in the sample:

 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.

The API registers this mode with:

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.
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:

 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:

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

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

1
2
3
{
  "active": false
}

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

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");
}

A strict host opts into this behavior explicitly:

1
builder.AddJwtAuthenticationWithIntrospection();

Reproduce immediate rejection

  1. Start the API with the introspection extension instead of the default extension.
  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.
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:

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

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:

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:

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 default. 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:

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:

1
2
3
4
5
// Fast default: token is accepted while locally valid.
builder.AddJwtAuthentication();

// Strict mode: revoked token is rejected on the next API call.
builder.AddJwtAuthenticationWithIntrospection();

Choose local JWT validation for the normal scalable path. Choose introspection for APIs where 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