Practice: Tokens and JWT validation
Generated by Claude (Fable 5.1) by hand from the identity and integration ops spike, Scout learning center on 2026-10-14.
Main tool: PyJWT · PyJWT does the whole validation checklist in one decode call, has a JWKS client that performs the kid lookup and caching, and raises a distinct exception for each failed check, so breaking a token on purpose shows you exactly which check caught it. It is the validator the learning center used to verify real tokens from Keycloak and node-oidc-provider. · Alternatives: panva/jose in Node and the browser does the same with a remote JWKS helper; auth0/node-jsonwebtoken is the most installed Node library (read its defaults); lestrrat-go/jwx in Go; the jwt.io debugger for reading a token; jwt_tool and the step CLI for attacking or inspecting tokens from the shell.
Every step links into the tool's own documentation; links were checked on 2026-10-14 · version 1
Setup (one time)
1. Create a scratch Python virtual environment and install PyJWT with its cryptography extra plus requests. No global install.
PyJWT is the validator used in the lesson's proof; the crypto extra is what makes RS256 and the JWKS client work.
Explained in: PyJWT usage: validating tokens and fetching keys from a JWKS endpoint (pyjwt.readthedocs.io), checked 2026-10-14
Done when: python -c 'import jwt, cryptography' exits 0 inside the environment.
2. Have a local OpenID provider running and an ID token to work on: follow the OpenID Connect practice path's setup (Keycloak 26.4 in Docker on 127.0.0.1:18080 with the realm, user and public client) and run its authorization code flow once, keeping the id_token and the issuer URL.
Every step below works on a real token from a real provider, so the errors you see are the ones a customer's token would produce.
Explained in: Keycloak: Getting started with Docker (www.keycloak.org), checked 2026-10-14
Done when: You hold a string with two dots whose first segment base64url-decodes to a JSON object with alg and kid, and the issuer's discovery document answers 200.
Steps
1. Split the token on the dots and base64url-decode the header and the payload by hand (add padding, then decode). Name alg, typ and kid from the header and iss, aud, sub, exp, iat and nonce from the payload.
Decode first, validate second: the header tells you which key and algorithm the validator will need.
Explained in: jwt.io: Introduction to JSON Web Tokens (jwt.io), checked 2026-10-14
Done when: You have printed both JSON objects and can say what each of the eight named fields is for without looking it up.
2. Fetch the provider's discovery document, then its jwks_uri. Find the key whose kid equals the token header's kid and note its kty, use and alg.
The kid lookup is the join between a token and the provider's published keys; seeing the JSON once makes the rotation story concrete.
Explained in: RFC 7517: JSON Web Key (JWK) (datatracker.ietf.org), checked 2026-10-14
Done when: The JWKS lists a key with the token's kid, and you can explain what the verifier would do if it did not.
3. Validate the token with PyJWT: PyJWKClient for the key, algorithms pinned to the header's RS256 (or the provider's ES256), audience set to the client id, issuer set to the discovery document's issuer, and a require list of exp, iat, iss, aud and sub. Compare the returned nonce with the one you sent.
This is the one call a relying party makes on every login; the arguments are the checklist.
Explained in: PyJWT usage: validating tokens and fetching keys from a JWKS endpoint (pyjwt.readthedocs.io), checked 2026-10-14
Done when: jwt.decode returns the claims dictionary and the nonce matches; the learning center's run printed 'id_token VALID' at this point.
4. Flip one byte of the payload: decode it, change the email claim to mallory@example.test, re-encode without padding, reassemble with the original header and signature, and validate again.
The signature covers the encoded bytes exactly; this is the proof that reading a claim without verifying is reading the attacker's claim.
Explained in: RFC 7515: JSON Web Signature (JWS) (datatracker.ietf.org), checked 2026-10-14
Done when: PyJWT raises InvalidSignatureError ('Signature verification failed') and nothing else.
5. Validate the genuine token again with audience set to a different client id.
A token for another application from the same provider passes every other check; aud is the one that says 'this token is for me'.
Explained in: RFC 7519: JSON Web Token (JWT) (datatracker.ietf.org), checked 2026-10-14
Done when: PyJWT raises InvalidAudienceError ('Audience doesn't match').
6. Validate the genuine token with algorithms=['none'], and then with algorithms=['HS256'] and the public key's PEM as the secret. Then forge a key-confusion token: an HS256 header, the same payload, and an HMAC-SHA256 over them with the public key's PEM bytes as the secret; validate it with algorithms=['HS256'] and the PEM.
A verifier that lets the token choose the algorithm fails both; your library's refusal is the behaviour to keep when you configure it.
Explained in: RFC 8725: JSON Web Token Best Current Practices (datatracker.ietf.org), checked 2026-10-14
Done when: The first two calls raise InvalidAlgorithmError ('The specified alg value is not allowed'): the verifier's allow-list, not the token's header, picks the algorithm. The forged token raises InvalidKeyError ('The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret'). You can name the two attacks (alg none, key confusion) these refusals prevent.
7. In the Keycloak admin console set the public client's access token lifespan to 60 seconds, log in again, wait a little past expiry (but less than two minutes) and validate the new token; then validate once more with leeway=120.
exp is only as strong as the leeway; seeing both outcomes sets the number.
Explained in: Microsoft Entra: Secure applications and APIs by validating claims (learn.microsoft.com), checked 2026-10-14
Done when: The first validation raises ExpiredSignatureError and the second passes, and you can say what leeway a production verifier should allow (a minute or two, not more).
8. Rotate the provider's key: in the realm's key providers add a new RSA generated key with a higher priority, log in again and compare the new token's kid with the old. Validate the new token with a PyJWKClient instance created before the rotation, then with a fresh one.
Key rotation is the most common cause of an SSO outage that 'nobody caused'; a verifier that refetches on an unknown kid survives it.
Explained in: RFC 7517: JSON Web Key (JWK) (datatracker.ietf.org), checked 2026-10-14
Done when: The old kid and the new kid differ; both validations succeed, because PyJWKClient in current PyJWT refetches the JWKS once when a kid is missing from its cache, with a cooldown so a flood of bad tokens cannot become a flood of fetches; you can describe that cache-and-refetch rule and say what a verifier that caches forever would have done.
9. Read the token storage guidance and the OWASP cheat sheet's storage section, then write the storage rule for one client of yours: where the access token lives, where the refresh token lives, their lifetimes, and what revocation actually does.
The storage decision is the one a customer's security review will ask about; writing it down from the sources makes the answer defensible.
Explained in: Auth0: Token Storage (auth0.com), checked 2026-10-14
Done when: Your note names a storage place for each token that is not local storage and states honestly what happens to an issued access token when the user is deprovisioned.