TT Lab
Get started
Learn Learning paths Courses

Keycloak and Enterprise Identity

Verifying a JWT Without Asking the Server

Continue in TT Lab

Summary

JWT verification is not asking the authentication server; it is a local operation that checks the signature with a public key and inspects the claims. That is why it is fast.

Why this was needed

There are two ways to check whether a token is valid. You can ask the authentication server's introspection endpoint, or verify the token's own signature.

The former is accurate. Even a token that was just revoked comes back invalid immediately. In exchange, every request has a network round trip. If ten microservices each ask, the authentication server becomes the bottleneck.

The latter finishes without a network. You only need the public key, and the public key can be cached. In exchange, it cannot reflect revocation immediately. Until the token expires, it appears valid.

The practical answer is settled. Keep access tokens short (5–15 minutes) and use local verification. You limit the revocation delay to that lifetime. If you also use refresh token rotation, you get a practical invalidation effect without a blacklist.

How it works

A JWT is three parts separated by dots: header, payload, signature. The first two are merely Base64URL encoded, not encrypted. Anyone can read them. That is why you must not put secrets in a JWT.

The verification order is as follows.

First look at alg and kid in the header. If alg is none or differs from what you expect, reject it immediately. There have actually been incidents where signature bypass occurred because this was not checked.

Use kid to find the corresponding public key at the JWKS endpoint. Cache the JWKS but refresh it once if you cannot find the kid — this is needed at key rotation.

Verify the signature. With RS256, it is RSA verification with the public key.

Then look at the claims. exp (expiry), iss (is the issuer the very server we know), aud (is this token meant for our API), and, if needed, nbf and azp.

The mistake of omitting the aud check is especially common. A token for another API issued by the same authentication server would pass at our API.

Confusing an ID token with an access token is also common. An ID token is for the client app to confirm "who the user is" and its aud is the client. For API calls, you must use the access token.

What you meet in the field

With key rotation, several keys exist at the same time in the JWKS. That is why you select by kid. If you set the cache TTL too long, all verifications fail right after a rotation, and if too short, requests pile up on the authentication server. You usually cache for a few hours but refresh immediately on a kid miss.

Five things you must check in verification

It is not enough for the signature alone to match. You must check all five.

Claim Check If you do not check
iss Is it an issuer we know A token issued elsewhere passes
aud Is this service the intended audience A token for another service passes
exp Has it not expired It becomes valid forever
nbf Is it still before the validity period A future token passes
Signature Was it signed with the issuer's key A forged token passes

Omitting aud is the most common. You can then call our API with the access token of another application that uses the same authentication server. If there are several microservices, always check it.

The algorithm confusion attack

You must not leave the algorithm to the library. The moment you trust the alg in the token header as is, an attacker changes it to none or HS256 and sends it.

정상: {"alg": "RS256", "kid": "abc"}   ← 공개키로 검증
공격: {"alg": "none"}                   ← 서명 없이 통과시키려는 시도
공격: {"alg": "HS256"}                  ← 공개키를 HMAC 비밀키로 쓰게 유도

The second one is especially tricky. The public key of RS256 is a value everyone knows, but if you use it as the symmetric key of HMAC, the attacker can create a valid signature.

# ❌ 헤더를 믿는다
jwt.decode(token, key)

# ✅ 우리가 알고리즘을 정한다
jwt.decode(token, key, algorithms=["RS256"], audience="labhub-api",
           issuer="https://auth.labhub/realms/labhub")

Key rotation and the JWKS cache

The authentication server changes keys periodically. The application picks the right public key by kid (the key identifier), and when it meets an unknown kid, it downloads the JWKS again.

1. 토큰 헤더의 kid 를 본다
2. 캐시에 있으면 그 키로 검증
3. 없으면 JWKS 엔드포인트를 다시 받는다 (여기에 속도 제한을 건다)

If step 3 has no limit, an attacker can pour in requests carrying unknown kids and bring down the authentication server. Limit it to a few times per minute, and in between use only the cached keys.

Keep the cache lifetime short (5–15 minutes), but during a rotation the old key and the new key must both be valid. That is why the authentication server keeps a period when it publishes both keys.

What you will do in the next lab

You get a token from Keycloak, take it apart into three parts, check the claims, find the key in the JWKS and verify the signature, and even confirm that verification fails when you tamper with the payload. At the end you compare the elapsed time of introspection and local verification over 100 runs each.