Keycloak Token Revocation

Tested on Red Hat Enterprise Linux 10.2 (Coughlan)
Package podman-5.8.2-5.el10_2.x86_64
quay.io/keycloak/keycloak:26.7.3
Applies to Any Linux host with Podman or Docker
Privilege Normal user for rootless Podman; Keycloak admin access for session-revocation demos
Lab environment Keycloak 26.7.3 on http://127.0.0.1:8080/ — Install Keycloak with Podman
Scope RFC 7009 revocation with curl on Keycloak 26.7.3, separate refresh-token and access-token revoke flows, introspection vs local JWT verification after revoke, privacy-preserving HTTP 200 responses, and comparison of endpoint revoke, OIDC logout, admin session termination, and client secret rotation after compromise. Does not cover refresh-token rotation settings, full OIDC logout topology, or JWKS signature verification code.
Related guides Keycloak token introspection
Keycloak refresh tokens
Keycloak sessions explained
Access token vs ID token vs refresh token
Keycloak public vs confidential clients
Short on time?Beta Get the gist of this 14-minute read in seconds, then ask follow-up questions

To revoke a Keycloak access or refresh token, POST the token to:

/realms/{realm}/protocol/openid-connect/revoke

using OAuth 2.0 token revocation (RFC 7009).

A mobile banking app stores a refresh token so it can renew access without asking for the password on every launch. When the user taps Sign out everywhere, or when fraud detection flags the device, you need to stop that refresh token from working — and you may also need to cut off access tokens that are still within their five-minute lifetime.

POST the token to Keycloak's revocation endpoint and Keycloak invalidates it according to the token type:

  • Access token — marks that token inactive on Keycloak-side checks
  • Refresh token — can also remove related client/session state
  • Self-contained JWT at the API — not automatically invalidated when the API only checks signature and exp locally

This lab revokes refresh and access tokens separately, compares introspection before and after, and contrasts endpoint revocation with logout and admin session termination.

The walkthrough uses:

  • Realm golinuxcloud
  • Confidential client refresh-lab-app
  • Introspection client introspect-lab-api
  • User tokenuser on Keycloak 26.7.3

For how introspection reports active: false after session changes, see Keycloak token introspection.


What token revocation changes in Keycloak

Token revocation affects Keycloak's server-side view of a token, but the effect depends on the token type and on how your API validates access tokens.

  • Revoking an access token marks it inactive for introspection, UserInfo, and other Keycloak-backed checks
  • Revoking a refresh token stops future refresh grants and can remove related client/session state and user consent for that client
  • Resource servers that only verify JWT signature and exp locally are not notified; they may keep accepting the token until exp
  refresh-lab-app                         introspect-lab-api
  (owns the tokens)                       (checks active state)
        |                                        |
        |  POST /revoke                          |  POST /token/introspect
        v                                        v
  Keycloak 26.7.3  ── session + token state ──►  active: true / false
        |
        |  APIs that verify JWT locally only
        v
  May still accept token until exp (no server call)

Side effects in the lab realm:

  • Tokens issued during the curl steps are revoked; run a fresh login before other labs if needed
  • Client secret for refresh-lab-app stays RefreshLabSecret!2026 unless you keep a rotated value

What OAuth token revocation does

RFC 7009 defines a single revocation endpoint where a client can ask the authorization server to invalidate a token. The request is intentionally simple:

  • token — the access or refresh token to revoke
  • token_type_hint — optional; access_token or refresh_token helps Keycloak find the right record faster
  • Client authentication — required for confidential clients

Revocation changes whether Keycloak still considers the token valid:

  • Refresh token — future refresh requests fail
  • Access token — introspection, UserInfo, and other Keycloak-backed checks recognize the revocation
  • Resource server with a cached JWT — not notified automatically
Token type What revocation typically stops
Refresh token Future refresh grants; related client/session state and user consent for that client
Access token Introspection and UserInfo reject the token; APIs that introspect see active: false
Access token at a JWT-only API Often still accepted until exp unless the API calls introspection or uses very short lifetimes

Revocation changes server-side state. It does not erase the JWT string from every copy the client already handed to an API.


Keycloak revocation endpoint

Keycloak publishes the URL in OpenID Provider metadata:

bash
curl -s 'http://127.0.0.1:8080/realms/golinuxcloud/.well-known/openid-configuration' | python3 -c 'import sys,json; print(json.load(sys.stdin)["revocation_endpoint"])'
output
http://127.0.0.1:8080/realms/golinuxcloud/protocol/openid-connect/revoke

The revocation endpoint can be used by the client that owns the token:

  • Confidential clients authenticate with their client credentials
  • Public clients cannot use a client secret; their revocation request follows the client-authentication rules for that client type
  • This lab uses confidential client refresh-lab-app with HTTP Basic authentication — the same client that obtained the tokens

Set base variables once:

bash
export KC_BASE='http://127.0.0.1:8080/realms/golinuxcloud/protocol/openid-connect'
export TOKEN_CLIENT='refresh-lab-app'
export TOKEN_SECRET='RefreshLabSecret!2026'
export INTROSPECT_CLIENT='introspect-lab-api'
export INTROSPECT_SECRET='paste-introspect-lab-api-secret'

Copy INTROSPECT_SECRET from Clients → introspect-lab-api → Credentials → Client secret. If introspect-lab-api or the audience mapper on refresh-lab-app is missing, follow the setup in Keycloak token introspection first.

This lab uses Direct Access Grant (grant_type=password) only to obtain test tokens quickly. Do not use that grant in production applications; use Authorization Code + PKCE or another appropriate flow instead.

Obtain a token pair for tokenuser:

bash
TOKEN_RESP=$(curl -s -X POST "${KC_BASE}/token" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -d 'grant_type=password' \
  -d 'username=tokenuser' \
  -d 'password=TokenUser!2026' \
  -d 'scope=openid profile')

Store the tokens in shell variables:

bash
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
REFRESH_TOKEN=$(printf '%s' "$TOKEN_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["refresh_token"])')

The response includes expires_in for the access token and refresh_expires_in for the refresh token. Revocation can end usability before those counters reach zero.


Revoke a refresh token

When a device is lost or a refresh token is exfiltrated, revoke the refresh token so the client cannot silently mint new access tokens.

In Keycloak, revoking a refresh token is broader than simply invalidating that one string:

  • Keycloak revokes the user's consent for the client
  • Keycloak may remove the associated client-session state

Post the refresh token to /revoke with a hint:

bash
curl -s -w "\nHTTP status: %{http_code}\n" -X POST "${KC_BASE}/revoke" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "token=${REFRESH_TOKEN}" \
  --data-urlencode 'token_type_hint=refresh_token'

Keycloak returns an empty body with HTTP 200 when the request is accepted. That success does not prove the token existed; RFC 7009 allows the server to respond uniformly so callers cannot probe which tokens are valid.

Try to refresh with the same refresh token:

bash
curl -s -X POST "${KC_BASE}/token" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -d 'grant_type=refresh_token' \
  --data-urlencode "refresh_token=${REFRESH_TOKEN}"
output
{"error":"invalid_grant","error_description":"Session not active"}

The refresh JWT may still decode, but Keycloak rejects it because the backing session or refresh record is no longer active. That is the same class of failure you see after Keycloak refresh token rotation with Revoke Refresh Token enabled or after admin session termination.

token_type_hint is optional. Keycloak can often infer the token type, but supplying refresh_token or access_token avoids ambiguity when a deployment stores both in similar formats.


Revoke an access token

Obtain a fresh token pair before this section so the refresh token from the previous step is not needed:

bash
TOKEN_RESP=$(curl -s -X POST "${KC_BASE}/token" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -d 'grant_type=password' \
  -d 'username=tokenuser' \
  -d 'password=TokenUser!2026' \
  -d 'scope=openid profile')
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')

Confirm introspection still reports the access token as active:

bash
curl -s -X POST "${KC_BASE}/token/introspect" \
  -u "${INTROSPECT_CLIENT}:${INTROSPECT_SECRET}" \
  --data-urlencode "token=${ACCESS_TOKEN}"
output
{
    "exp": 1790267866,
    "iat": 1790267566,
    "iss": "http://127.0.0.1:8080/realms/golinuxcloud",
    "aud": ["introspect-lab-api", "orders-api", "account"],
    "preferred_username": "tokenuser",
    "client_id": "refresh-lab-app",
    "active": true
}

The active: true line is what your API should read first.

Revoke the access token:

bash
curl -s -w "\nHTTP status: %{http_code}\n" -X POST "${KC_BASE}/revoke" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "token=${ACCESS_TOKEN}" \
  --data-urlencode 'token_type_hint=access_token'

Post the same access token to introspection again:

bash
curl -s -X POST "${KC_BASE}/token/introspect" \
  -u "${INTROSPECT_CLIENT}:${INTROSPECT_SECRET}" \
  --data-urlencode "token=${ACCESS_TOKEN}"
output
{"active":false}

Keycloak-side checks now treat the token as inactive even though its exp claim may still be in the future. UserInfo agrees:

bash
curl -s -w "\nHTTP status: %{http_code}\n" -H "Authorization: Bearer ${ACCESS_TOKEN}" "${KC_BASE}/userinfo"
output
HTTP status: 401

What changes after access-token revocation:

  • Introspection / UserInfo — pick up the revocation immediately
  • JWT-only API — may still accept the token until exp; the next section shows why

Why a JWT may still validate locally

Does revoking a Keycloak token invalidate the JWT immediately?

  • Keycloak-backed check — introspection returns active: false immediately
  • Local JWT validation only — the API may continue accepting the token until exp

A locally validated JWT is self-contained:

  • The API checks signature and claims using Keycloak's public key
  • It does not ask Keycloak whether the token was revoked after issuance
  • After revocation, the unchanged string in memory still has a valid signature and a future exp

Decode the payload without calling Keycloak:

bash
python3 << 'PY'
import json, base64, time
import os
token = os.environ["ACCESS_TOKEN"]
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
print("preferred_username:", claims.get("preferred_username"))
print("exp still in future:", claims.get("exp", 0) > int(time.time()))
PY
output
preferred_username: tokenuser
exp still in future: True
  Revoke at Keycloak                Resource server (JWT only)
        |                                    |
        |  marks token inactive              |  reads exp + signature
        v                                    v
  introspect -> active: false          may still return 200 OK

That gap trips up many production rollouts: revocation does not rewrite JWTs already issued. Mitigations include:

  • Short access-token lifetimes plus refresh
  • Token introspection at the API for high-risk routes
  • Session logout or admin session termination when you need all tokens from a login to fail Keycloak-backed checks at once

For token-type basics and what each credential is for, see access token vs ID token vs refresh token.


Revocation vs logout vs session termination

These controls overlap but target different layers.

Action What it affects
/revoke access token The submitted access token's server-side validity
/revoke refresh token The refresh token plus related client grant/session state; Keycloak also revokes the user's consent for that client
OIDC logout The login/session behind the sign-out flow
Admin logout Broader user-session termination

OIDC logout endpoint

Obtain a new token pair, then call logout with the refresh token:

bash
TOKEN_RESP=$(curl -s -X POST "${KC_BASE}/token" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -d 'grant_type=password' \
  -d 'username=tokenuser' \
  -d 'password=TokenUser!2026' \
  -d 'scope=openid profile')
REFRESH_TOKEN=$(printf '%s' "$TOKEN_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["refresh_token"])')
ACCESS_TOKEN=$(printf '%s' "$TOKEN_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')

Post the refresh token to the logout endpoint to end the backing session:

bash
curl -s -w "\nHTTP status: %{http_code}\n" -X POST "${KC_BASE}/logout" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "refresh_token=${REFRESH_TOKEN}"
output
HTTP status: 204

Refresh and introspection after logout match refresh-token revocation:

  • Refresh returns invalid_grant
  • Introspection on the still-unexpired access token returns active: false

Logout is the user-facing sign-out path. /revoke is the protocol endpoint for revoking a specific token value, but the effects can be broader for refresh tokens because Keycloak may also invalidate related client/session state.

Admin session termination

Operators can end every session for a user in the Admin Console:

  • Users → tokenuser → Sessions — use Log out all sessions
  • Sessions under the realm menu — review active logins across clients

Keycloak user tokenuser Sessions tab listing client sessions with Log out all sessions control

The realm Sessions screen shows the same records from an administrator's view across clients.

Keycloak realm Sessions page listing active user and client sessions in golinuxcloud

The realm Sessions page lists active user sessions across clients. Multiple rows here came from repeated lab logins; these rows represent sessions, not individual access tokens.

The Admin REST API performs the same work programmatically. List sessions, then log the user out entirely:

bash
ADMIN_TOKEN=$(curl -s -X POST 'http://127.0.0.1:8080/realms/master/protocol/openid-connect/token' \
  -d 'grant_type=password' -d 'client_id=admin-cli' \
  -d 'username=admin' -d 'password=YOUR_ADMIN_PASSWORD' | \
  python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')

TOKENUSER_ID=$(curl -s \
  'http://127.0.0.1:8080/admin/realms/golinuxcloud/users?username=tokenuser&exact=true' \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["id"])')

curl -s -w "\nHTTP status: %{http_code}\n" -X POST \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  "http://127.0.0.1:8080/admin/realms/golinuxcloud/users/${TOKENUSER_ID}/logout"
output
HTTP status: 204

After admin logout:

  • Introspection and other Keycloak-backed checks report affected access tokens as inactive
  • Refresh grants return Session not active
  • A resource server that only validates JWT signature and exp locally can still accept an already-issued token until it expires

For how user, client, and SSO sessions relate to those tokens, see Keycloak sessions explained.


Compromise response patterns

Use the smallest control that matches what was leaked.

Scenario First response Also consider
Refresh token copied from one device POST /revoke with token_type_hint=refresh_token Review the user's sessions and investigate how the token was exposed
Access token intercepted on the wire Revoke it if APIs honor revocation, and contain the incident; otherwise the practical protection is short token lifetime plus session/account controls Introspection on sensitive APIs; TLS everywhere
Confidential client secret exposed Regenerate client secret in Credentials Invalidate outstanding tokens; audit redirect URIs
Account takeover suspected Admin Log out all sessions for the user Disable user; rotate passwords; revoke refresh tokens per client
API still honors revoked JWTs Not a Keycloak-only fix Add introspection or reduce access-token TTL

Regenerating the client secret:

  • Stops future token requests that use the old secret
  • Does not itself revoke access tokens already issued

Keycloak refresh-lab-app Credentials tab showing Client secret field and Regenerate button after compromise

Pair secret rotation with session logout or per-token revocation when you need immediate effect on outstanding credentials.

Rotate through the Admin API when automating incident response:

bash
curl -s -X POST \
  -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  "http://127.0.0.1:8080/admin/realms/golinuxcloud/clients/CLIENT_UUID/client-secret"

The response body includes the new value. Update your deployment secret store before deleting the old one.


Troubleshoot revocation expectations

Symptom Likely cause Fix
API still accepts token after /revoke Resource server verifies JWT locally only Add introspection or shorten access-token TTL
/revoke returns 200 but token still works at Keycloak Wrong token string or wrong client credentials Revoke with the owning client; copy the full token
Refresh fails with Session not active after revoke Expected — refresh or session was ended Obtain a new login; do not retry the old refresh token
Introspection active: false but JWT exp valid Expected — server state trumps local exp Teach API owners the difference
/revoke body shows invalid_token with HTTP 200 Malformed or unrecognized token string (Keycloak 26.7.3 behavior) RFC 7009 still uses 200; verify effect with introspection or refresh
Invalid client at /revoke Client authentication or client identification is incorrect Check the client ID and use the authentication method configured for that client

Privacy-preserving HTTP 200

  • RFC 7009 — HTTP 200 for both a successfully revoked token and an invalid/unknown token; the response body is ignored
  • Keycloak 26.7.3 lab — a malformed token also returned an invalid_token JSON body while keeping HTTP status 200; do not build client logic around that JSON body
  • Already-revoked token — HTTP 200 with an empty body
bash
curl -s -w "\nHTTP status: %{http_code}\n" -X POST "${KC_BASE}/revoke" \
  -u "${TOKEN_CLIENT}:${TOKEN_SECRET}" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'token=not-a-real-token' \
  --data-urlencode 'token_type_hint=access_token'
output
{"error":"invalid_token","error_description":"Invalid token"}
HTTP status: 200

Do not treat HTTP 200 alone as proof that a live token was present. Confirm effect with a refresh attempt or introspection instead.


Summary

You used Keycloak's RFC 7009 revocation endpoint to invalidate refresh and access tokens separately:

  • Refresh token revoked — next grant_type=refresh_token call returns invalid_grant
  • Access token revoked — introspection returns active: false and UserInfo returns HTTP 401
  • Same JWT decoded locally — exp can still look valid until expiry

The operational lesson: revocation updates Keycloak's server-side token and session state. It does not reach APIs that trust self-contained JWTs until expiry unless they call introspection or you keep access tokens very short.

Wider controls when one token is not enough:

  • Logout / admin session termination — cast a wider net than revoking a single token string
  • Client secret rotation — stops new token requests with the old secret; does not by itself invalidate access tokens already in circulation

For the next step in enforcement, wire Keycloak token introspection on APIs that must honor logout and revoke immediately.


References


Frequently Asked Questions

Does revocation immediately kill a JWT?

Not at every API:

  • Keycloak marks the token inactive on the server, so introspection and UserInfo fail immediately
  • A resource server that only checks signature and exp locally may keep accepting the JWT until exp

Add introspection or very short access-token lifetimes when APIs must honor revocation immediately.

What happens when a refresh token is revoked?

  • The refresh grant returns invalid_grant, often with Session not active
  • Keycloak revokes the user's consent for that client
  • The client must send the user through a new login

Should I use token_type_hint?

It is optional. Passing refresh_token or access_token can help Keycloak look up the token efficiently, but the hint does not determine whether the token can be revoked. If the hint is incorrect, RFC 7009 says the authorization server should ignore it rather than treat the token as a different credential.

Revocation vs logout?

  • /revoke — invalidates the specific token you post
  • OIDC /logout with a refresh token — ends the user session behind that login and typically invalidates related refresh and access tokens for Keycloak-backed checks

Choose revoke for one credential; choose logout for sign-out UX.

Why does the endpoint return 200 for an invalid token?

RFC 7009 uses a uniform success response so callers cannot probe which tokens ever existed. Verify effect with introspection or a refresh attempt instead of relying on the response body alone.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)