JWT Fundamentals: Structure, Claims, and the JOSE Family

⚠️ This post is generated by LLM, read with caution.

JSON is the default currency of modern APIs, and it turns out a security token can be plain JSON too. A JWT — JSON Web Token, standardized by the IETF as RFC 7519 — is a “compact claims representation format intended for space constrained environments such as HTTP Authorization headers and URI query parameters.” Read that second half again: the entire format exists to carry structured identity data in places where bulkier formats struggle to fit.

This post builds one from raw JSON with nothing but the Python standard library, splits it apart, tries to forge one, and then traces the standards family it belongs to — JWS, JWE, JWK — and how they fit together.

The token: three parts, two of them readable by anyone

The mental model to get right first: a JWT is not an envelope, it is a signed document. In the usual signed form, parts 1 and 2 are legible to anyone, anywhere, with no secret required; part 3 is the cryptographic claim that exactly this string, produced with exactly this key, was issued. Since the script builds the token by hand, you can see exactly what each part is made of — base64url-encoded JSON for the header and the claims, raw MAC bytes for the signature:

"""Build a JWT from scratch, split it into its three parts, and show
what the third part (the signature) does and does not buy you."""

import base64
import hashlib
import hmac
import json


# --- base64url: the encoding every JWT part uses -------------------------
def b64url_encode(data: bytes) -> str:
    # URL-safe alphabet; trailing '=' padding omitted
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def b64url_decode(text: str) -> bytes:
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def json_b64url(obj) -> str:
    return b64url_encode(json.dumps(obj, separators=(",", ":")).encode())


# --- a shared key, 32 octets (HS256 = HMAC-SHA256) ------------------------
SECRET = b"0123456789abcdef" * 2  # 32 bytes, as RFC 7518 section 3.2 requires for HS256

# --- build the token -------------------------------------------------------
now = 1750000000  # fixed for the demo so the token is reproducible

header = {
    "alg": "HS256",           # how the signature was computed
    "typ": "JWT",             # declares the payload to be a JWT
    "kid": "hmac-key-2025",   # hint: which key the verifier should use
}

claims = {
    # registered claim names (RFC 7519 section 4.1)
    "iss": "https://accounts.example.com",
    "sub": "user-98765",
    "aud": "https://api.example.com",
    "iat": now,
    "nbf": now,
    "exp": now + 900,
    "jti": "token-2025-0001",
    # private claim names (RFC 7519 section 4.3)
    "role": "report-reader",
    "dept": "billing",
}

h = json_b64url(header)
p = json_b64url(claims)
signing_input = f"{h}.{p}".encode("ascii")
sig = b64url_encode(hmac.new(SECRET, signing_input, hashlib.sha256).digest())
token = f"{h}.{p}.{sig}"


def verify(token: str, key: bytes) -> tuple[bool, bytes, bytes]:
    """Recompute the HMAC over 'part1.part2' and compare to part 3."""
    part1, part2, part3 = token.split(".")
    expected = hmac.new(key, f"{part1}.{part2}".encode(), hashlib.sha256).digest()
    actual = b64url_decode(part3)
    return hmac.compare_digest(expected, actual), expected, actual


print("=== 1. the finished token, as it travels in an HTTP header ===")
print(token)
print()
print(f"periods: {token.count('.')}  ->  {token.count('.')} + 1 = "
      f"{len(token.split('.'))} dot-separated parts")

print()
print("=== 2. decoding the parts needs NO key ===")
print("part 1 (JOSE header) ->", json.dumps(json.loads(b64url_decode(h))))
print("part 2 (claims)      ->", json.dumps(json.loads(b64url_decode(p))))
sig_raw = b64url_decode(sig)
print(f"part 3 (signature)   -> {len(sig_raw)} raw octets "
      f"({len(sig_raw) * 8} bits -- SHA-256-sized MAC bytes, not JSON)")

print()
print("=== 3. verification with the RIGHT key (the 'kid' hint points to it) ===")
ok, expected, actual = verify(token, SECRET)
print("recomputed HMAC:", expected.hex()[:32], "...")
print("embedded  HMAC :", actual.hex()[:32], "...")
print("MATCH" if ok else "MISMATCH", "->", "accept" if ok else "reject")

print()
print("=== 4. verification with a DIFFERENT key ===")
WRONG = b"deadbeef" * 4  # another 32-byte key
ok, expected, _ = verify(token, WRONG)
print("recomputed HMAC:", expected.hex()[:32], "...")
print("MISMATCH" if not ok else "MATCH", "->", "reject" if not ok else "accept")

print()
print("=== 5. tamper: flip 'role' to admin, keep the ORIGINAL signature ===")
forged_claims = dict(claims, role="admin")
forged = f"{h}.{json_b64url(forged_claims)}.{sig}"
print("the attacker's payload still decodes fine -- no key required for that:")
print("  ", json.dumps(json.loads(b64url_decode(forged.split(".")[1]))))
ok, expected, actual = verify(forged, SECRET)
print("recomputed HMAC:", expected.hex()[:32], "...")
print("embedded  HMAC :", actual.hex()[:32], "...")
print("MISMATCH" if not ok else "MATCH", "->", "reject" if not ok else "accept")

print()
print("=== 6. the claim name classes, per the RFC's own taxonomy ===")
REGISTERED = {"iss", "sub", "aud", "exp", "nbf", "iat", "jti"}
PUBLIC = {"https://acme.example/claims/max-age"}   # Public Name: contains a Collision-Resistant Name (URI)
for name in claims:
    if name in REGISTERED:
        kind = "registered (RFC 7519 4.1, IANA registry)"
    elif name in PUBLIC:
        kind = "public (RFC 7519 4.2)"
    else:
        kind = "private (RFC 7519 4.3)"
    print(f"  {name:6} -> {kind}")

print()
print("=== 7. one changed byte -> an entirely different MAC ===")
orig_mac = hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest()
forged_mac = hmac.new(SECRET, f"{h}.{json_b64url(forged_claims)}".encode(),
                      hashlib.sha256).digest()
print("original :", orig_mac.hex()[:40], "...")
print("forged   :", forged_mac.hex()[:40], "...")
print("identical prefixes:", orig_mac.hex()[:8] == forged_mac.hex()[:8])

Here it is run end to end — build, decode, verify with the right key, verify with the wrong key, and tamper:

=== 1. the finished token, as it travels in an HTTP header ===
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImhtYWMta2V5LTIwMjUifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmV4YW1wbGUuY29tIiwic3ViIjoidXNlci05ODc2NSIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIiwiaWF0IjoxNzUwMDAwMDAwLCJuYmYiOjE3NTAwMDAwMDAsImV4cCI6MTc1MDAwMDkwMCwianRpIjoidG9rZW4tMjAyNS0wMDAxIiwicm9sZSI6InJlcG9ydC1yZWFkZXIiLCJkZXB0IjoiYmlsbGluZyJ9.3w_CB78ohZ1SgpAhAmRG_A9M41gJOQr8JdhpHJIvqCo

periods: 2  ->  2 + 1 = 3 dot-separated parts

=== 2. decoding the parts needs NO key ===
part 1 (JOSE header) -> {"alg": "HS256", "typ": "JWT", "kid": "hmac-key-2025"}
part 2 (claims)      -> {"iss": "https://accounts.example.com", "sub": "user-98765", "aud": "https://api.example.com", "iat": 1750000000, "nbf": 1750000000, "exp": 1750000900, "jti": "token-2025-0001", "role": "report-reader", "dept": "billing"}
part 3 (signature)   -> 32 raw octets (256 bits -- SHA-256-sized MAC bytes, not JSON)

=== 3. verification with the RIGHT key (the 'kid' hint points to it) ===
recomputed HMAC: df0fc207bf28859d52829021026446fc ...
embedded  HMAC : df0fc207bf28859d52829021026446fc ...
MATCH -> accept

=== 4. verification with a DIFFERENT key ===
recomputed HMAC: 7393804f4ba133e4e567b8c2d7712111 ...
MISMATCH -> reject

=== 5. tamper: flip 'role' to admin, keep the ORIGINAL signature ===
the attacker's payload still decodes fine -- no key required for that:
   {"iss": "https://accounts.example.com", "sub": "user-98765", "aud": "https://api.example.com", "iat": 1750000000, "nbf": 1750000000, "exp": 1750000900, "jti": "token-2025-0001", "role": "admin", "dept": "billing"}
recomputed HMAC: 07a85a217ca3c989d9844176a6521bd3 ...
embedded  HMAC : df0fc207bf28859d52829021026446fc ...
MISMATCH -> reject

=== 6. the claim name classes, per the RFC's own taxonomy ===
  iss    -> registered (RFC 7519 4.1, IANA registry)
  sub    -> registered (RFC 7519 4.1, IANA registry)
  aud    -> registered (RFC 7519 4.1, IANA registry)
  iat    -> registered (RFC 7519 4.1, IANA registry)
  nbf    -> registered (RFC 7519 4.1, IANA registry)
  exp    -> registered (RFC 7519 4.1, IANA registry)
  jti    -> registered (RFC 7519 4.1, IANA registry)
  role   -> private (RFC 7519 4.3)
  dept   -> private (RFC 7519 4.3)

=== 7. one changed byte -> an entirely different MAC ===
original : df0fc207bf28859d52829021026446fc0f4ce358 ...
forged   : 07a85a217ca3c989d9844176a6521bd3a524ce2b ...
identical prefixes: False

Read that output in order, because each step narrows down what the signature actually buys:

  • Decoding (step 2) is free. The header and the full claims set are plain JSON sitting behind an alphabet swap; nobody needed the 32-byte key to see role: report-reader.
  • With the right key (step 3), recomputing the HMAC over part1.part2 reproduces the embedded MAC byte for byte — df0fc207bf28859d....
  • With a different 32-byte key (step 4), the recomputed MAC is something else entirely (7393804f4ba133e4...). Verification fails because the MAC is a function of the key, not just the message.
  • The tamper (step 5) is the payoff. The attacker’s payload with role: admin still decodes perfectly — the signature is doing nothing to hide it — but the recomputed MAC (07a85a217ca3...) doesn’t match the embedded one, so the token is rejected.
  • And step 7 shows why no clever patching works: one changed byte in the signed input produced a MAC with no shared prefix at all with the original. That avalanche is what makes “edit one character of the token” futile.

So the signature binds the encoding, not the meaning: it authenticates the exact base64url text of the first two parts, says nothing about what a human would do with them, and degrades to total mismatch on any change.

The claims inside, and their three classes of names

Step 6 of the run classifies the token’s claims using the RFC’s own taxonomy, and it’s worth stopping on what the classification actually is. RFC 7519 §4 defines three classes of claim names — note, names, not values:

  • Registered (§4.1): iss, sub, aud, exp, nbf, iat, jti — names in the IANA “JSON Web Token Claims” registry, deliberately short because compactness is a core goal. The RFC is explicit that none of them is mandatory in all cases; “applications using JWTs should define which specific claims they use and when they are required or optional.”
  • Public (§4.2): names built from a Collision-Resistant Name — a URI, an OID, a UUID — so two vendors can define claims without clobbering each other.
  • Private (§4.3): everything else, agreed between the specific producer and consumer, and “subject to collision and should be used with caution.”

In our token, iss through jti land in the registered class and role/dept in the private class. The deeper point: the classification governs who may use the name, not what the claim means. That’s why the RFC can also say (at the end of §4) that any claim a recipient doesn’t understand MUST be ignored — the format has no way to know your business semantics, and that’s by design.

The JOSE family: JWS, JWE, JWK, and the key lookup

JWT is defined in terms of its siblings, and the relationships are easy to state once you’ve seen the token. Per RFC 7519 §1, JWTs encode their claims “as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted,” and “JWTs are always represented using the JWS Compact Serialization or the JWE Compact Serialization.” JWS is RFC 7515 (signing/MAC), JWE is RFC 7516 (encryption), and JWK — RFC 7517 — is simply “a JSON data structure that represents a cryptographic key,” with a JWK Set representing a collection of them.

How does a verifier even know which of the compact serializations it’s holding? RFC 7516 §9 gives several equivalent methods; the most visible is the segment count — a JWS compact serialization has three segments (two periods), a JWE has five (four periods). The header parameters carry the routing information that matters before any signature work begins:

  • alg — which algorithm produced part 3 (RFC 7519 §8.2 notes that of the JWA signature/MAC algorithms, only HS256 and none are required of a conforming JWT implementation).
  • typ — declares the content to be a JWT; per RFC 7519 §5.1 it’s “ignored by JWT implementations; any processing of this parameter is performed by the JWT application.”
  • cty — structural content type, useful for nested JWTs; RFC 7519 §5.2 says it’s not recommended in the ordinary non-nested case.
  • crit — RFC 7515 §4.1.11: lists extension header parameters that a recipient MUST understand; if it doesn’t, the JWS is invalid.
  • The key-reference set: jku (a URL that would serve a JWK Set), jwk (the key embedded in the header), kid (an id to look up), and the X.509 family x5u (certificate URL), x5c (certificate chain), x5t (SHA-1 thumbprint), x5t#S256 (SHA-256 thumbprint).

The second script takes the same token and walks shape → JWK packaging → kid-driven key lookup, which is the part of the family most people only touch in key-rotation incidents:

"""The JOSE family: JWT sits inside JWS, JWE is the encryption sibling,
JWK is how keys travel. Standard library only, HS256 (shared-key) case."""

import base64
import hashlib
import hmac
import json


def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def b64url_decode(text: str) -> bytes:
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def json_b64url(obj) -> str:
    return b64url_encode(json.dumps(obj, separators=(",", ":")).encode())


SECRET = b"0123456789abcdef" * 2  # this key, as raw bytes (32 octets)
ROTATED = b"fedcba9876543210" * 2  # a different 32-byte key ("the 2024 one")

header = {"alg": "HS256", "typ": "JWT", "kid": "hmac-key-2025"}
claims = {
    "iss": "https://accounts.example.com",
    "sub": "user-98765",
    "aud": "https://api.example.com",
    "iat": 1750000000,
    "exp": 1750000900,
    "jti": "token-2025-0001",
    "role": "report-reader",
}

h = json_b64url(header)
p = json_b64url(claims)
sig = b64url_encode(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
token = f"{h}.{p}.{sig}"


def classify(compact: str) -> str:
    """RFC 7519 section 7.2, step 6: determine whether the JWT is a JWS or a JWE."""
    periods = compact.count(".")
    if periods == 2:
        return "JWS compact serialization (3 parts: header.payload.signature)"
    if periods == 4:
        return "JWE compact serialization (5 parts: header.encrypted-key.iv.ciphertext.tag)"
    return "not a JWS/JWE compact serialization"


def verify(token: str, key: bytes) -> bool:
    part1, part2, part3 = token.split(".")
    expected = hmac.new(key, f"{part1}.{part2}".encode(), hashlib.sha256).digest()
    return hmac.compare_digest(expected, b64url_decode(part3))


print("=== 1. what the shape tells you ===")
print("our token has", token.count("."), "periods")
print("  ->", classify(token))
# a synthetic 5-part string, just to exercise the other branch of the check
fake_jwe = ".".join(["eyF9", "eyJ2IjoiMjU2In0", "QUJDREVGR0hJSgotQUJD",
                     "QUJDREVGR0hJSktMQUJDREVGR0hJSktMQUJD", "QUJDREVGR0hJSktM"])
print("synthetic 5-part string (obviously not a real JWE) ->", classify(fake_jwe))

print()
print("=== 2. the same key, repackaged as a JWK (RFC 7517) ===")
jwk = {
    "kty": "oct",                      # key type: octet sequence (symmetric)
    "alg": "HS256",
    "kid": "hmac-key-2025",           # the id the token's header points at
    "k": b64url_encode(SECRET),       # the key itself, base64url-encoded
}
print("JWK       :", json.dumps(jwk))
recovered = b64url_decode(jwk["k"])
print("round trip: decode 'k' ->", recovered)
print("identical to the original key bytes:", recovered == SECRET)

print()
print("=== 3. kid: header -> JWK Set -> key, the whole key lookup ===")
# a JWK Set is just {"keys": [...]} -- what a 'jku' URL would serve
jwk_set = {"keys": [
    {"kty": "oct", "alg": "HS256", "kid": "hmac-key-2024", "k": b64url_encode(ROTATED)},
    jwk,
]}
print("header's 'kid':", header["kid"])
print("JWK Set kids  :", [k["kid"] for k in jwk_set["keys"]])

wanted = header["kid"]
match = next((k for k in jwk_set["keys"] if k.get("kid") == wanted), None)
print(f"lookup by kid={wanted!r} ->", "found" if match else "NOT FOUND")

for kid, key in [("hmac-key-2024", ROTATED), ("hmac-key-2025", SECRET)]:
    ok = verify(token, key)
    print(f"verify with key {kid}: ", "ACCEPT (signature matches)" if ok
          else "REJECT  (signature does not match)")

print()
print("=== 4. why 'alg' and 'typ' are in the header at all ===")
print(json.dumps(json.loads(b64url_decode(h))))
KNOWN_7519 = {"alg", "typ", "cty", "crit", "jku", "jwk", "kid", "x5u", "x5c", "x5t", "x5t#S256"}
print("parameters in our header  :", sorted(header))
print("all of them recognized    :", all(k in KNOWN_7519 for k in header))
print("alg: which algorithm produced part 3. typ: what kind of content")
print("the token is (a JWT). Both are metadata the verifier needs before it")
print("can even start checking the signature.")
=== 1. what the shape tells you ===
our token has 2 periods
  -> JWS compact serialization (3 parts: header.payload.signature)
synthetic 5-part string (obviously not a real JWE) -> JWE compact serialization (5 parts: header.encrypted-key.iv.ciphertext.tag)

=== 2. the same key, repackaged as a JWK (RFC 7517) ===
JWK       : {"kty": "oct", "alg": "HS256", "kid": "hmac-key-2025", "k": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY"}
round trip: decode 'k' -> b'0123456789abcdef0123456789abcdef'
identical to the original key bytes: True

=== 3. kid: header -> JWK Set -> key, the whole key lookup ===
header's 'kid': hmac-key-2025
JWK Set kids  : ['hmac-key-2024', 'hmac-key-2025']
lookup by kid='hmac-key-2025' -> found
verify with key hmac-key-2024:  REJECT  (signature does not match)
verify with key hmac-key-2025:  ACCEPT (signature matches)

=== 4. why 'alg' and 'typ' are in the header at all ===
{"alg": "HS256", "typ": "JWT", "kid": "hmac-key-2025"}
parameters in our header  : ['alg', 'kid', 'typ']
all of them recognized    : True
alg: which algorithm produced part 3. typ: what kind of content
the token is (a JWT). Both are metadata the verifier needs before it
can even start checking the signature.

Three things to pull out of that run. The shape check shows the family at a glance: two periods means the compact JWS form (header.payload.signature); the synthetic five-part string trips the JWE branch (header.encrypted-key.iv.ciphertext.tag) — the script labels it as obviously not a real JWE, and it isn’t, it just exercises the classifier. The JWK section shows that a symmetric key is the same bytes whether it lives in a variable or in a JWK: decode the base64url k member and you’re back to b'0123456789abcdef0123456789abcdef', the signing secret, exactly. And the kid lookup is the whole key-rotation story in six lines: the header says hmac-key-2025, the JWK Set holds both hmac-key-2024 and hmac-key-2025, and only the 2025 key verifies — the 2024 key is rejected because its MAC differs, not because anything about the token’s format changed.

Implementations, and the applications that use them

One distinction the RFC draws that’s easy to blur in practice: a JWT implementation is not a JWT application. RFC 7519 keeps those jobs separate in a few places. The format layer’s obligation is bounded — §4 is unambiguous that “in the absence of such requirements, all claims that are not understood by implementations MUST be ignored,” and §7.2 adds that “it is an application decision which algorithms may be used in a given context,” even after a JWT has been successfully validated. The application layer, meanwhile, is what decides which claims are required for this audience (aud), what role: report-reader is allowed to do, and whether HS256 is acceptable in this deployment.

In practice that boundary is where most real JWT failures live: a token can pass every check an implementation performs — correct base64url, valid JSON, matching MAC — and still be completely wrong for the receiving service, because the application didn’t check aud, or accepted a token whose exp the library was configured not to enforce. “The signature verified” and “this request is authorized” are different sentences, and the second one is yours to write.

Takeaway

A JWT is three compact-serialized parts — legible header, legible claims, and a signature over the exact encoding of both, where a single changed byte voids it — carrying claim names drawn from one of three namespaces (IANA-registered, collision-resistant public, or privately agreed) and signed with a key whose identity the header advertises. The format tells you that something was issued by the holder of a key; only the application decides what that means.