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, reviewed by a second reader (Claude Fable 5.1), Scout learning center on 2026-10-14.

Concept: JWT and token validation · also covers Identity and access, Threat modelling and authorization design · Lesson 2 of 10 in Identity and integration ops · version 1

Practice this

A token is the thing a client shows to prove a login or a permission already happened. Opaque tokens mean nothing until the issuer is asked about them; JSON Web Tokens carry their claims and a signature, so a resource server can validate them locally. This lesson is the three parts of a JWT, the claims that matter (iss, aud, sub, exp, nbf, iat, jti, nonce), the validation checklist every verifier has to run, how a verifier finds the signing key by kid in a JWKS and survives key rotation, where a client may keep a token, and the known attacks (alg none, key confusion, kid injection) that each check exists to stop. The practice breaks tokens on purpose until each check produces its named error.

Read more

How it is done in practice

An integration engineer meets JWTs from three sides. As the relying party of a customer's identity provider, the app validates ID tokens: signature against the provider's JWKS, issuer, audience, expiry and nonce, with a library that does all of it in one call and a configuration that pins the algorithm. As the owner of an API, the service validates access tokens the same way and additionally checks the scope or roles claim before doing anything. As the person on the troubleshooting call, the engineer decodes a token from a HAR file with a one-liner, reads iss, aud, exp and kid, and compares them with the provider's discovery document to say in a minute whether the problem is a wrong audience, an expired token, or a key the cache has not refreshed. The library choice (PyJWT, jose, jsonwebtoken, jwx) matters less than reading its defaults once: which algorithms it accepts, whether it checks audience by default, and what leeway it gives the clock.

Technologies

JWTJWSJWK and JWKSPyJWTjoseRS256 and ES256Token introspectionKeycloakjwt.io debuggerjwt_tool

Diagram

flowchart LR
  T["Token: header.payload.signature"] --> H["Read header: alg, kid, typ"]
  H --> P{"alg in the verifier's allow-list?"}
  P -- no --> R["Reject"]
  P -- yes --> K["Find kid in the cached JWKS"]
  K -- unknown kid --> F["Refetch JWKS once, rate-limited"] --> K
  K --> V["Verify signature over the encoded bytes"]
  V --> C["iss equals issuer, aud contains me, exp and nbf with small leeway, nonce for an ID token"]
  C --> OK["Read sub, scope, roles"]
flowchart LR
  T["Token: header.payload.signature"] --> H["Read header: alg, kid, typ"]
  H --> P{"alg in the verifier's allow-list?"}
  P -- no --> R["Reject"]
  P -- yes --> K["Find kid in the cached JWKS"]
  K -- unknown kid --> F["Refetch JWKS once, rate-limited"] --> K
  K --> V["Verify signature over the encoded bytes"]
  V --> C["iss equals issuer, aud contains me, exp and nbf with small leeway, nonce for an ID token"]
  C --> OK["Read sub, scope, roles"]

Code

python
# Validate an ID token against the provider's JWKS with PyJWT (the learning center's check, trimmed)
import jwt, requests
from jwt import PyJWKClient

disc = requests.get(ISSUER + "/.well-known/openid-configuration").json()
hdr = jwt.get_unverified_header(id_token)              # {'alg': 'RS256', 'typ': 'JWT', 'kid': '...'}
key = PyJWKClient(disc["jwks_uri"]).get_signing_key_from_jwt(id_token)   # kid lookup, cached
claims = jwt.decode(
    id_token, key.key,
    algorithms=["RS256"],                               # the verifier pins the algorithm, never the token
    audience=CLIENT_ID, issuer=disc["issuer"],
    options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
assert claims["nonce"] == nonce_we_sent
# a changed payload byte -> jwt.InvalidSignatureError; the wrong audience -> jwt.InvalidAudienceError
python
# Decode without verifying, to read a token from a HAR file or a ticket (never trust what you read this way)
import base64, json
def part(tok, i):
    seg = tok.split(".")[i]
    return json.loads(base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4)))
print(part(token, 0))   # header: alg, kid, typ
print(part(token, 1))   # claims: iss, aud, sub, exp, iat, ...

Subtopics

Opaque tokens and self-contained tokens

Two ways to carry a fact

An opaque token is a random string. The resource server that receives it learns nothing from it and has to ask the issuer, by token introspection (RFC 7662) or a session lookup, whether it is active and what it means. A self-contained token, almost always a JWT, carries its claims and a signature, so the resource server can check it with the issuer's public key and no round trip. Opaque tokens are easy to revoke (the issuer just stops answering yes) and leak nothing when logged; JWTs are fast to validate and work across services and data centres, but they are valid until they expire whatever the issuer does in the meantime, and anything in them is readable by anyone who holds them, because a signature is not encryption.

What a practitioner chooses

The usual split is a JWT for the access token sent to APIs (validated locally at every hop, short-lived), an opaque or JWT refresh token held only by the client and exchanged at the issuer, and an opaque session cookie for the browser. Providers differ: Keycloak and Entra issue JWT access tokens by default, Okta's org authorization server issues opaque access tokens that only Okta can introspect, and a customer's API gateway may translate between the two. Ask the provider's discovery document and read one token before writing the validator: a JWT has two dots and a base64url-decodable header; an opaque token has neither.

JWT structure and the claims that matter

Three parts, two dots

A JWT in compact serialization is header.payload.signature, each part base64url-encoded without padding. The header (a JOSE header, RFC 7515) names the algorithm (alg), the token type (typ, JWT or at+jwt for access tokens under RFC 9068) and usually the key id (kid). The payload is a JSON object of claims. The signature is computed over the first two parts exactly as encoded, so re-encoding the payload with a changed field breaks it. Decoding the header and payload needs no key and is the first move on any token: decode, read, then validate.

The registered claims

RFC 7519 registers seven: iss, the issuer, a URL that must equal the provider's issuer value exactly, trailing slash included; sub, the subject, a stable identifier of the user or client, never an email; aud, the audience, the client or API this token is for, one string or a list; exp, the expiry, seconds since the epoch; nbf, not before; iat, issued at; jti, a unique id that lets a server refuse a replay. OpenID Connect adds nonce (binds the ID token to one authorization request), auth_time, azp (the authorized party when aud has several entries) and the profile claims (email, name, preferred_username, groups when a mapper adds them). An access token under RFC 9068 adds client_id and scope. Everything else is provider-specific; the Entra claims reference is a good example of how long that list gets and which entries an app may rely on.

The validation checklist

Every verifier does all of this

The order matters little; doing all of it matters completely. Parse the header and pin the algorithm: the verifier decides which algorithms it accepts (RS256 or ES256 for a provider's tokens) and refuses anything else, including none; the token's alg field is never allowed to choose. Find the key (next subtopic) and verify the signature over the exact encoded bytes. Check iss against the configured issuer. Check that aud contains this application's identifier; a token for another client is not yours even though the same provider signed it. Check exp and nbf against the clock with a small leeway (a minute or two at most) for skew. Check typ when the provider sets it, so an ID token is not accepted as an access token. For an ID token, check that nonce equals the one this session sent. Then, and only then, read the claims the application needs: sub to identify the user, scope or roles to authorize the call.

In code

Libraries do this in one call when configured; the defaults are the risk. PyJWT's decode takes algorithms, audience and issuer and a require list, and raises a distinct exception per failure (InvalidSignatureError, InvalidAudienceError, ExpiredSignatureError, ImmatureSignatureError, MissingRequiredClaimError). The learning center's test client validated an ID token that way against a local Keycloak and node-oidc-provider and then confirmed that a token with one payload byte changed fails on the signature and a token presented with the wrong audience fails on aud. A verifier that passes those two tests and refuses alg none has the three checks attackers test for first.

JWKS, kid lookup and key rotation

Where the key comes from

A provider publishes its public signing keys as a JSON Web Key Set (RFC 7517) at the jwks_uri named in its discovery document. Each key has a kid, a kty (RSA or EC), a use (sig) and the public parameters. The verifier reads the token's kid from the header, finds the matching key in the set, and verifies with it. The learning center's run against Keycloak 26.4 shows the shape: the ID token header named alg RS256 and a kid, and the JWKS held an RSA key with that kid; the Newman collection later asserted the same thing by fetching the JWKS and checking the kid was listed.

Rotation without an outage

Providers rotate signing keys, some on a schedule, some on demand. A rotation works because the JWKS can hold several keys at once: the provider adds the new key, starts signing with it, and keeps the old one published until every token signed with it has expired. The verifier's part is a cache with a refresh rule: cache the JWKS (minutes to hours), and when a token arrives with a kid that is not in the cache, fetch the JWKS once more before rejecting, with a rate limit so a flood of bad tokens cannot turn into a flood of fetches. A verifier that caches forever breaks on the first rotation, which is the 'SSO stopped working on a Tuesday and nobody changed anything' ticket. The matching failure on the provider side is a certificate or key that expired; the troubleshooting runbook lesson has the symptoms.

Token storage, lifetime and revocation

Where a client may keep a token

A browser app should keep tokens in memory and nowhere else: local storage and session storage are readable by any script that runs in the page, so a single XSS leaks every token in them, which is why the vendor storage guidance and the OWASP cheat sheet say the same thing. When a page reload must not log the user out, the current answer is a backend-for-frontend that holds the tokens server-side behind an HttpOnly cookie, or a refresh token in a cookie scoped to the token endpoint. A native or server application keeps secrets in the platform's keychain or a secrets manager, never in a config file in the repository. Logs are a storage place too: a bearer token in an access log is a credential in a file.

Lifetimes and the limits of revocation

Because a JWT validates locally, revoking it means waiting for exp. That is the argument for short access tokens (five to fifteen minutes is common; the practice realm uses five) paired with a refresh token that the issuer can refuse, with refresh token rotation so a stolen refresh token is detected when both copies try to use it. When a customer needs immediate revocation (an employee leaves, a device is lost), the options are introspection on every call (back to opaque semantics, at the cost of a round trip), a short-lived cache of a revocation list, or back-channel logout, which the OpenID Connect lesson covers. Say which of these the integration does in the design doc; 'tokens are revoked at logout' is not true of a JWT unless one of them is in place.

The known attacks and the check that stops each

The RFC 8725 list

The JWT best current practice reads as a list of past incidents. alg none: a token with alg set to none and an empty signature was accepted by libraries that trusted the header; the fix is an allow-list of algorithms chosen by the verifier. Key confusion (RS256 to HS256): a verifier that accepts both an asymmetric and a symmetric algorithm can be given a token signed with HMAC using the public key as the secret, and it verifies; the fix is the same allow-list, one family per key. kid injection: a verifier that uses the kid as a file path or a database key can be made to read a key of the attacker's choosing; the fix is to look kids up only in the fetched JWKS. Embedded jwk or jku headers: a token that carries its own key or a URL to one must not be trusted to name the key that verifies it. Missing audience or issuer checks: a token for another app from the same provider passes the signature test. Unlimited leeway: a clock skew allowance of hours makes exp meaningless.

Learn them by doing them

The PortSwigger JWT labs let you perform each of these against a deliberately weak app: forge a token with alg none, swap a kid, inject a jwk header. The jwt_tool and step CLI repositories listed with the practice do the same from the shell against your own service. An hour of breaking tokens teaches the checklist better than a page of reading, and it is the quickest way to review a customer's hand-rolled validator: try the four forgeries and see which ones it accepts.

Sources

Related lessons

Practice this