TT Lab
Get started
Learn Learning paths Courses

Sessions and Tokens — From the Browser to the Mesh

Token revocation — introspection, revocation, blocklist

Continue in TT Lab

Goal

Revoke a token issued by Keycloak with RFC 7009, check the result with RFC 7662 introspection, and then prove with real requests how the three methods on the resource server, local verification, introspection, and a jti denylist, react to a revoked token.

Why it matters

A signed JWT is verified by the resource server alone, so even if you end the session at the issuer, the API does not know it and the token passes until expiry. RFC 7009 also says that immediate revocation of a self-contained token needs a separate integration and that the alternative is a short lifetime. So the designer chooses among three — introspection, which asks the issuer every time (immediate, but costs a round trip and couples availability), a denylist on the resource server (immediate, but needs a state store), and a short TTL (simple, but late by the maximum lifetime). This lab places the three methods side by side on one server and has you measure for yourself whether a single revoked token is 200 or 401 on each, and how many milliseconds the cost comes to.

Steps

  1. If Keycloak is off, turn it on with lab-start-keycloak and wait with /root/st/revoke/wait.sh until http://127.0.0.1:8080/realms/labhub becomes 200 (at most 240 seconds). Write the time it took into /root/st/revoke/ready.txt as ready_seconds=<정수> (the placeholder is an integer).
  2. Source /opt/fixtures/kc/realm-info.env, get a token with the password grant (user dev1) of the public client web-app, and save the whole response body JSON in /root/st/revoke/token.json.
  3. Make /root/st/revoke/introspect.sh <토큰> (the placeholder is the token) call the introspection endpoint with the credentials of api-svc and print the response JSON as is. Read the secret from env. A new token gives active=true and a fake one gives active=false.
  4. Make /root/st/revoke/revoke.sh <refresh 토큰> (the placeholder is the refresh token) send client_id=web-app, token, and token_type_hint=refresh_token to the RFC 7009 revocation endpoint, and write the results before and after revocation into /root/st/revoke/revoke.txt as before=true, after=false, and refresh_after=400.
  5. Bring up /root/st/revoke/api.py at 127.0.0.1:8307 (/health, /local/me that looks only at the JWKS, and /introspect/me that introspects on every request). Measure the two paths at least 10 times with /root/st/revoke/bench.py and write n, local_ms, introspect_ms, access_ttl, and max_exposure_s into /root/st/revoke/latency.txt.
  6. Add GET /guarded/me (checks the denylist revoked:jti:<jti>) and POST /logout (puts the jti on the denylist for its remaining lifetime and revokes the refresh at Keycloak) to api.py and bring it up again.
  7. Test the whole flow with /root/st/revoke/e2e.sh and leave seven lines in /root/st/revoke/e2e.out.

Notes

Turn on Keycloak and wait for the realm to be ready

If Keycloak is off, turn it on with lab-start-keycloak and wait with /root/st/revoke/wait.sh until http://127.0.0.1:8080/realms/labhub becomes 200 (at most 240 seconds). Write the time it took into /root/st/revoke/ready.txt as ready_seconds=<정수> (the placeholder is an integer).

Keycloak is a JVM, so even after the port opens it takes more time to load the realm. Instead of a fixed sleep, poll the status code in a while loop. You check whether it is on with lab-status.

Get a dev1 token pair with web-app

Source /opt/fixtures/kc/realm-info.env, get a token with the password grant (grant_type=password, user dev1) of the public client web-app, and save the whole response body JSON in /root/st/revoke/token.json. It must have access_token, refresh_token, and expires_in.

The token endpoint is KC_TOKEN_URL in env. A public client sends only client_id, with no secret. The password has special characters, so use --data-urlencode.

Check active with introspection

Make /root/st/revoke/introspect.sh <토큰> (the placeholder is the token) call the RFC 7662 introspection endpoint (/realms/labhub/protocol/openid-connect/token/introspect) with the credentials of the confidential client api-svc and print the response JSON as is. Do not write the secret in a file; read it from realm-info.env. A new token must give active=true and a fake string must give active=false.

Caller authentication is required for introspection (to prevent token scanning). Give Basic authentication with curl -u and POST the token as a form. For an inactive token, it is normal that no information other than active comes back.

Revoke the refresh token with RFC 7009

Make /root/st/revoke/revoke.sh <refresh 토큰> (the placeholder is the refresh token) send client_id=web-app, token, and token_type_hint=refresh_token to the RFC 7009 revocation endpoint (/realms/labhub/protocol/openid-connect/revoke). With a new token pair, write the introspection of the access token before and after revocation and the refresh grant result after revocation into /root/st/revoke/revoke.txt as before=true, after=false, and refresh_after=400.

The revocation endpoint is 200 whether it succeeded or the token was invalid, so you cannot tell from the response code alone whether it took effect. Check the result with introspection. Even a public client is rejected if it leaves out client_id.

Local verification vs introspection — revocation and latency

Bring up /root/st/revoke/api.py at 127.0.0.1:8307. GET /health is 200, GET /local/me verifies the Bearer token only with the JWKS (signature, exp, iss, aud=lab-api), and GET /introspect/me judges by introspection's active on every request (both 200 on success and 401 on failure). Then measure the two paths with the same token at least N times (10 or more) with /root/st/revoke/bench.py and write n=, local_ms=, introspect_ms=, access_ttl= (the token's exp-iat), and max_exposure_s= (the maximum seconds a revoked token can live with local verification alone) into /root/st/revoke/latency.txt.

PyJWT's PyJWKClient fetches and caches the JWKS. The local path does not ask the issuer, so even a revoked token gets 200 until expiry — at this step that is the right result, and the length of that window is exactly the token lifetime. If you cache the introspection result, the revocation is reflected late.

The jti denylist and logout

Add to /root/st/revoke/api.py GET /guarded/me (after local verification, it is 401 if the Redis key revoked:jti:<jti> exists) and POST /logout (it stores the Bearer token's jti as revoked:jti:<jti> with a TTL of the token's remaining lifetime, revokes the refresh_token in the form body at Keycloak with RFC 7009, and then returns 200), and bring it up again. After logout, /guarded/me with the same token must be 401.

The TTL is exp minus the current time. An expired token is rejected at signature verification anyway, so there is no reason to remember it after that. With the denylist alone, the session on the Keycloak side is alive and you can receive a new token with the refresh, so send the revocation request as well. If you changed the code, you have to take the server down and bring it up again.

Prove the revocation strategies in one flow

With /root/st/revoke/e2e.sh, get a new token and test in the order introspection, then /guarded/me, then POST /logout, then /guarded/me and /local/me, then introspection, then the refresh grant, and leave introspect_before=true, guarded_before=200, logout=200, guarded_after=401, local_after=200, introspect_after=false, and refresh_after=400 in /root/st/revoke/e2e.out, one per line.

You use the introspect.sh and the server from the earlier steps as they are. The grader does not look only at the file; it runs e2e.sh once more anew to see whether the same seven lines come out. That local_after is 200 is the window that a short TTL fills.