Sessions and Tokens — From the Browser to the Mesh
Token revocation — introspection, revocation, blocklist
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
- If Keycloak is off, turn it on with
lab-start-keycloakand wait with/root/st/revoke/wait.shuntilhttp://127.0.0.1:8080/realms/labhubbecomes 200 (at most 240 seconds). Write the time it took into/root/st/revoke/ready.txtasready_seconds=<정수>(the placeholder is an integer). - Source
/opt/fixtures/kc/realm-info.env, get a token with the password grant (userdev1) of the public clientweb-app, and save the whole response body JSON in/root/st/revoke/token.json. - Make
/root/st/revoke/introspect.sh <토큰>(the placeholder is the token) call the introspection endpoint with the credentials ofapi-svcand print the response JSON as is. Read the secret from env. A new token givesactive=trueand a fake one givesactive=false. - Make
/root/st/revoke/revoke.sh <refresh 토큰>(the placeholder is the refresh token) sendclient_id=web-app,token, andtoken_type_hint=refresh_tokento the RFC 7009 revocation endpoint, and write the results before and after revocation into/root/st/revoke/revoke.txtasbefore=true,after=false, andrefresh_after=400. - Bring up
/root/st/revoke/api.pyat 127.0.0.1:8307 (/health,/local/methat looks only at the JWKS, and/introspect/methat introspects on every request). Measure the two paths at least 10 times with/root/st/revoke/bench.pyand writen,local_ms,introspect_ms,access_ttl, andmax_exposure_sinto/root/st/revoke/latency.txt. - Add
GET /guarded/me(checks the denylistrevoked:jti:<jti>) andPOST /logout(puts the jti on the denylist for its remaining lifetime and revokes the refresh at Keycloak) toapi.pyand bring it up again. - Test the whole flow with
/root/st/revoke/e2e.shand leave seven lines in/root/st/revoke/e2e.out.
Notes
- You look at the realm address, client, user, and secret with
lab-envorcat /opt/fixtures/kc/realm-info.env. Do not write the values into scripts;sourcethem. - The revocation endpoint gives 200 even for an invalid token. You check whether it took effect with introspection.
- Common mistake 1: revoking only the access token — in this Keycloak the refresh stays alive and new access tokens keep coming out. Logout must revoke the refresh.
- Common mistake 2: not putting a TTL on the denylist entries — even expired tokens pile up forever. You only need to remember them for their remaining lifetime.
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.