cld-toys › Toys › jwt-from-scratch

Commentary: jwt-from-scratch

An allowlist of two strong algorithms, and a forged role: admin token it accepts anyway — signed with the server's own public key, using no secret at all. A study guide for jwt.py.

jwt-from-scratch/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with jwt.py open beside you. jwt.py is the toy itself (212 lines: two base64 helpers, three signature primitives, an encoder, and four verifiers); demo.py runs four acts against all four verifiers; oneline.py is the result reduced to a single toggled line; sweep.py is every counterfactual this page cites, kept runnable on purpose; test_jwt.py pins the failures. Stdlib only (base64, hashlib, hmac, json), no dependencies, no network — nothing here talks to a service, and no token on this page belongs to anything. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0), arm64 Apple Silicon, Python 3.15.0a8. There is no clock and no randomness in the toy, so every token on this page is byte-identical on every run — I ran demo.py twice and diffed: no output difference. The one exception is the timing table in §6.5, which is a measurement and is labelled as one.
cd jwt-from-scratch
python3 demo.py      # the aha (§6)
python3 oneline.py   # the load-bearing line, isolated (§6.4)
python3 sweep.py     # every counterfactual and boundary on this page (§6.5, §6.7)
python3 test_jwt.py  # pins the two forgeries so this page can't rot
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy implements a JSON Web Token end to end — base64url encoding, HMAC-SHA-256, a (deliberately toy) RSA signature — and then implements four verifiers, in the order a real codebase tends to acquire them:

what it doesforged tokens it accepts
verify_naivedispatches on header["alg"]both
verify_blocklist…but refuses "none"one
verify_allowlist…but only allows HS256 and RS256one
verify_strictnever reads header["alg"]none

The mechanism is algorithm agility: a signed token carries, in plaintext, the name of the algorithm you should use to check it. That is a genuinely useful feature — it is how a system rotates from RSA to ECDSA without a flag day — and it is also a field an attacker fills in.

The aha is the third row. alg: none is famous and dies at the second verifier; everyone has heard of it. The allowlist verifier is the fix a careful engineer writes, it contains only real, strong, standard algorithms, and it still hands back role: admin for a token minted by someone who knows no secrets at all. The one-line fix is not a longer list. It is deleting the line that reads alg out of the token.

By the end you should be able to:


2. The problem this mechanism exists to solve

A server wants to hand a client a small piece of state — "you are alice, you are an admin, until 3pm" — let the client carry it around, and accept it back later without a database lookup. The token has to survive a round trip through a party who wants to change it.

That is a message-authentication problem, and a MAC solves it. Sign the claims, check the signature, done. If the toy stopped there it would be a demonstration of HMAC and nothing else.

The reason JWT is more interesting than "HMAC with extra dots" is a second requirement layered on top: cryptographic agility. Deployments live for years. Algorithms get deprecated. A system that hardcodes one algorithm has to coordinate a simultaneous upgrade of every issuer and every verifier; a system that can negotiate one can roll forward incrementally. So JWS puts the algorithm name in the token, in a header the verifier reads first.

The competing goals that make more than one design defensible:


3. Background you need

None of this is deep, but the commentary leans on it hard.

ConceptWhere it's used hereOne source
The alg header verify_naive, jwt.py:156 — the line the whole page is about RFC 7515 §4.1.1
The JWS signing input split, jwt.py:135 — the encoded segments and the dot, not the parsed JSON RFC 7515 §5.1
HMAC takes arbitrary bytes as a key hs256, jwt.py:81-85 — which is why a public key works as a secret RFC 7518 §3.2
Asymmetric signatures publish the verification key PUBLIC_KEY, jwt.py:66-71 RFC 7518 §3.3
base64url b64e / b64d, jwt.py:37-44 — padding stripped, never transmitted RFC 7515 Appendix C
Constant-time comparison hmac.compare_digest, jwt.py:160 — and a measurement that fails to justify it (§6.5) hmac.compare_digest
Textbook RSA rs256_sign / rs256_check, jwt.py:88-103 — insecure by construction, see §7.3 RFC 7518 §3.3

The two that carry the result are rows 1 and 3. Row 1 is the hole: the verifier reads an instruction out of the attacker's document. Row 3 is what makes the hole exploitable without any secret: hmac.new(key, ...) accepts any bytes at all as a key, so a value published on purpose is a usable one. Neither of those is a cryptographic weakness. HMAC-SHA-256 is not broken here, and no signature on this page is a forgery in the cryptographic sense — every one of them verifies because it is genuinely correct.


4. The mental model

Before any code. A JWT is three base64url segments, and the signature covers the first two as text:

header segment payload segment signature segment ┌───────────────────┐ ┌─────────────────────────┐ ┌─────────────────────┐ eyJhbGciOiJIUzI1Ni… . eyJleHAiOjE3MzE1MzYwMDA… . GxRO95UmzYpcud7Wm0v… └───────────────────┴──┴─────────────────────────┘ this, exactly this ASCII, is the signing input │ ▼ alg = ??? ───────────────► MAC/verify ───► true / false ▲ │ read out of the header segment, which is the attacker's document.

The circularity is the mechanism. To check the signature you must first know which algorithm to use, and the only place that is written down is inside the thing you have not checked yet.

Now the two forgeries, side by side:

FORGERY A — alg: none header {"alg":"none","typ":"JWT"} <- attacker rewrites payload {"role":"admin",...} <- attacker rewrites sig (empty) <- attacker deletes verifier takes the `none` branch, never touches a key, returns the claims. FORGERY B — key confusion header {"alg":"HS256","typ":"JWT"} <- attacker rewrites payload {"role":"admin",...} <- attacker rewrites sig HMAC-SHA256(PUBLIC_KEY, signing_input) <- attacker COMPUTES server holds PUBLIC_KEY (to check RS256). server takes the HS256 branch, computes HMAC-SHA256(PUBLIC_KEY, ...), gets the same 32 bytes, and agrees. Nothing was broken. Both parties did the arithmetic correctly. The attacker's advantage is that the server's key is published.

Forgery A is a missing check. Forgery B is a check that passes, using a key the server itself hands out, and that is the one worth remembering. The intuition shortcut: an asymmetric verification key is public because it is only good for verifying — and the moment it reaches a symmetric algorithm, verifying and signing are the same capability again.


5. Reading the source

212 lines: nine small functions and four verifiers. Read it in this order.

5.1 The claim, stated in the module docstring

jwt.py · lines 11–21
That last sentence is the toy. The header is a document the attacker hands
you, and a verifier that reads `alg` out of it is asking the attacker how the
attacker should be checked. Four verifiers here, in the order a codebase
usually acquires them:

    verify_naive      dispatch on header["alg"]
    verify_blocklist  ... but refuse "none"
    verify_allowlist  ... but only allow HS256 and RS256
    verify_strict     never read header["alg"] at all

The first three all accept a forged token. See commentary section 6.

Four verifiers rather than two is a deliberate cost — it is 40 extra lines for what could have been "broken" and "fixed". It buys the only thing that distinguishes this page from a hundred blog posts about alg: none: an escalation. Each verifier is the honest, competent response to the previous failure, and the third one is still wrong. §7.1.

5.2 The key pair, and why the public key is bytes

jwt.py · lines 66–76
# PUBLIC_KEY is what the server holds in order to *verify* RS256, and what
# anybody at all can fetch from a /.well-known/jwks.json. It is bytes,
# because that is how a real verifier receives it: a PEM blob, a JWKS entry,
# a file read off disk. Section 6.3 is about what happens when those bytes
# reach a function that was expecting an HMAC secret.
PUBLIC_KEY = _json({"kty": "RSA", "n": N, "e": E})
PRIVATE_KEY = _json({"kty": "RSA", "n": N, "d": D})

# The shared secret for a symmetric deployment. Section 6.7 uses it to show
# where the attack stops working.
SECRET = b"correct horse battery staple"

PUBLIC_KEY being bytes rather than a parsed key object is the single most load-bearing modelling decision in the toy, and it is not a shortcut. In a real library the verifier is handed a PEM string, a JWKS blob, or a file it just read. rs256_check parses those bytes as a key; hs256 uses them as a key directly. One value, two functions, two entirely different meanings — and nothing in the type says which was intended.

Print it and you can see there is no secret in it:

{"e":65537,"kty":"RSA","n":3351951982485649274893506249551461531869841455148098344436679965392306817304849849230228707730300726176936796585226029889698681638201343575652945364008631}

182 bytes, all of them publishable. PRIVATE_KEY carries d instead of e and is the only thing in the file that must not leak.

5.3 The two primitives, and the asymmetry between them

jwt.py · lines 81–103
def hs256(signing_input, key):
    """HMAC-SHA-256. `key` is arbitrary bytes -- any bytes at all, which is
    exactly the property section 6.3 turns into a forgery.
    """
    return hmac.new(key, signing_input, hashlib.sha256).digest()


def rs256_sign(signing_input, priv):
    """Textbook RSA: s = h^d mod n, over the bare digest. Deterministic,
    unpadded, and insecure -- see the module docstring.
    """
    k = json.loads(priv)
    h = int.from_bytes(hashlib.sha256(signing_input).digest(), "big")
    return pow(h, k["d"], k["n"]).to_bytes(64, "big")


def rs256_check(signing_input, sig, pub):
    """s^e mod n == h. Note the asymmetry that makes RS256 worth having:
    this needs only `pub`, while rs256_sign needs `priv`.
    """
    k = json.loads(pub)
    h = int.from_bytes(hashlib.sha256(signing_input).digest(), "big")
    return pow(int.from_bytes(sig, "big"), k["e"], k["n"]) == h

Twenty-three lines, and the entire vulnerability is the contrast between the first function and the last two.

rs256_sign needs d. rs256_check needs only e and n. That gap is public-key cryptography: the capability to verify has been separated from the capability to sign, which is why it is safe to publish the verification key.

hs256 has no such gap. HMAC is symmetric — the same key both makes and checks a tag — and it will accept literally any byte string as that key. Put those two facts next to each other and the attack writes itself: take the value that was published because it only grants verification, and hand it to an algorithm where the two capabilities are the same one.

pow(h, d, n) and pow(s, e, n) are three-argument pow, which is CPython's modular exponentiation. That is the whole of RSA. It is also textbook RSA — no padding — which is fine here and catastrophic in production; §7.3 is about why the toy takes that trade.

5.4 split — what the signature actually covers

jwt.py · lines 129–135
def split(token):
    """(header, payload, signature bytes, signing input). The signing input
    is the first two segments and the dot between them, as ASCII -- not the
    decoded JSON, which is why re-serialising a header breaks a signature.
    """
    h, p, s = token.split(".")
    return json.loads(b64d(h)), json.loads(b64d(p)), b64d(s), f"{h}.{p}".encode()

Look at the fourth return value: f"{h}.{p}".encode(). The signing input is built from the still-encoded segments, not from re-serialising the parsed objects. RFC 7515 §5.1 requires exactly this, and it is not fussiness. JSON has no canonical form — key order and whitespace are free — so a verifier that re-encoded the parsed header would compute a different signing input than the signer did whenever the two libraries disagreed about formatting, and the honest tokens would fail.

The consequence for this page: an attacker cannot edit a header. Changing one character of the header segment changes the signing input, which changes the signature. §7.4 has the transcript. The header is signed data. The problem is not that it can be modified; the problem is that a verifier obeys it before checking that it wasn't.

5.5 verify_naive — v0, and the line the page is about

jwt.py · lines 148–169
def verify_naive(token, key, now=0):
    """v0. Asks the token how the token should be checked.

    Returns the payload on success, None on rejection. Read the first two
    lines of the body: `alg` comes out of the header, and the header came
    from whoever sent the token.
    """
    header, payload, sig, signing_input = split(token)
    alg = header["alg"]
    if alg == "none":
        ok = sig == b""
    elif alg == "HS256":
        ok = hmac.compare_digest(hs256(signing_input, key), sig)
    elif alg == "RS256":
        ok = rs256_check(signing_input, sig, key)
    else:
        return None
    if not ok:
        return None
    if "exp" in payload and payload["exp"] < now:
        return None
    return payload

This is the subsection the source docstring points at, so read it slowly.

alg = header["alg"] is the load-bearing line, and everything below it is competent. hmac.compare_digest is the constant-time comparison, correctly chosen. The RSA check is real. exp is checked. There is a default-deny else branch for unknown algorithms — this verifier does not accept "HS512" or "foo". The signature comparison is not ==.

None of it matters, because line two selects which of those careful checks runs, and line two reads a field the attacker wrote.

Note also if "exp" in payload — the expiry is enforced only when the claim is present. That is a second, independent bug and it is here on purpose; §7.5 measures it separately, because it needs the genuine signing key and is therefore an issuer/verifier mismatch rather than a forgery.

Returning the payload rather than True is a small deliberate choice: it means the demo can print role=admin and the reader sees what the forger actually gained, instead of a boolean. §7.6.

5.6 v1 and v2 — the two fixes that don't work

jwt.py · lines 172–190
def verify_blocklist(token, key, now=0):
    """v1. The reflex fix, and the one the industry shipped in 2015:
    refuse the algorithm that turned out to be a hole.
    """
    header, _payload, _sig, _si = split(token)
    if header["alg"] == "none":
        return None
    return verify_naive(token, key, now)


def verify_allowlist(token, key, now=0, allowed=("HS256", "RS256")):
    """v2. The careful fix. Name the algorithms you are prepared to accept,
    up front, and reject everything else. Both of these are real, strong,
    standard algorithms. This verifier is still broken -- section 6.3.
    """
    header, _payload, _sig, _si = split(token)
    if header["alg"] not in allowed:
        return None
    return verify_naive(token, key, now)

Both delegate to verify_naive after their own check. That is the experimental control: v0, v1 and v2 run literally the same verification code, so any difference between them is attributable to the added guard and to nothing else.

v1 is the patch the ecosystem actually shipped in 2015 after Tim McLean's disclosure. v2 is what you write if you read RFC 8725 §3.2 ("applications MUST only allow the use of cryptographically current algorithms") and stop there. It is a real improvement — it closes none, and every unknown or deprecated algorithm, permanently.

And it changes nothing about forgery B, because HS256 is on the list. It has to be: a list containing only RS256 is not an allowlist of algorithms, it is the fix in §5.7 written awkwardly. §7.1.

5.7 verify_strict — v3, and why the keyword isn't the fix

jwt.py · lines 193–212
def verify_strict(token, key, alg, now=0):
    """v3. The caller names the algorithm. `header["alg"]` is never read --
    the header is now signed *data*, not an instruction.

    `alg` must be the algorithm that matches `key`. Passing "HS256" here
    together with an RSA public key reproduces the whole bug (section 7.2),
    so the pairing is the fix, not the keyword.
    """
    _header, payload, sig, signing_input = split(token)
    if alg == "HS256":
        ok = hmac.compare_digest(hs256(signing_input, key), sig)
    elif alg == "RS256":
        ok = rs256_check(signing_input, sig, key)
    else:
        raise ValueError(alg)
    if not ok:
        return None
    if payload.get("exp", 0) < now:
        return None
    return payload

Compare with §5.5 line by line. The dispatch is the same. The primitives are the same. compare_digest is the same. The only differences are:

  1. _header is discarded — the underscore is the point;
  2. alg arrives as an argument;
  3. payload.get("exp", 0) instead of if "exp" in payload — a token with no expiry is now treated as expired rather than eternal.

The raise ValueError(alg) on an unknown algorithm rather than return None is deliberate: alg is now the server's input, so an unrecognised value is a programming error in the server, not a rejected token. Returning None would silently turn a typo in your own config into a total outage that looks like an authentication failure.

The keyword is not the fix. §7.2 shows verify_strict(..., "HS256", ...) against the RSA public key reproducing the forgery exactly. The fix is that alg and key are chosen together, by the party that owns both — which is what RFC 8725 §3.1 means when it says "each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed."


6. The demo, and what it proves

demo.py mints one honest RS256 token and runs four acts against all four verifiers.

python3 demo.py
=== act 0: the honest token === eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHA... length 186 chars peek header (no key) {'alg': 'RS256', 'typ': 'JWT'} peek payload (no key) {'exp': 1700003600, 'role': 'user', 'sub': 'alice'} verify_naive ok:user === act 1: tamper the payload, keep the signature === sub alice->alicf (1 byte) reject role user->admin reject ^ what everyone predicts. This is a MAC doing its job. === act 2: FORGERY A -- alg: none === eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjE3MzE1MzYwMDAsInJvbGUiOiJhZG1pbiIsInN1YiI6ImFsaWNlIn0. signature bytes 0 v0 naive ADMIN v1 -none reject v2 allow reject v3 strict reject === act 3: FORGERY B -- HS256/RS256 key confusion === the attacker knows only PUBLIC_KEY, which is public on purpose: {"e":65537,"kty":"RSA","n":3351951982485649274893506249551... (182 bytes) eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzE1MzYwMDAsInJvbGUiOiJhZG1pbiIsInN1YiI6ImFsaWNlIn0.GxRO95UmzYpcud7Wm0vg9xOwxCQ2Zf47URntQe9TdVs signature bytes 32 v0 naive ADMIN v1 -none ADMIN v2 allow ADMIN v3 strict reject ================================================================== token v0 naive v1 -none v2 allow v3 strict ------------------------------------------------------------------ honest RS256 ok:user ok:user ok:user ok:user alg:none ADMIN reject reject reject key confusion ADMIN ADMIN ADMIN reject ==================================================================

6.1 Acts 0 and 1: the honest token, and the aha everyone already has

Act 0 is a real signature. The claims are sub=alice, role=user, exp=1700003600NOW is frozen at 1_700_000_000, and the token expires 3600 seconds later, hence 1700003600. All four verifiers agree, so the rest of the page is not "a fix that rejects everything."

Act 1 is the aha this toy was originally supposed to have: change the payload and the signature fails. It does. Both mutations are surgical — alicealicf is one byte, and "user""admin" is the escalation an attacker would actually want — and both are rejected by every verifier (test_tampering_the_payload_is_caught).

That result is correct, reproducible, and worthless as a headline, because it is the definition of a MAC. Nobody predicts otherwise. It is act 1 here so you can see the machinery working before you see it defeated, and so that when acts 2 and 3 succeed you know it was not because the signature check was broken.

6.2 Act 2: alg: none, and a verifier that never touches a key

The whole forgery is one call:

demo.py · line 55
forged_none = jwt.encode(GREEDY, "none", None)

The None in the key position is not a typo — there is no key. The token is two base64 segments, a trailing dot, and 0 signature bytes. verify_naive takes the none branch, sets ok = sig == b"", and returns the claims.

How little the verifier consulted is worth measuring. From sweep.py C1:

C1 Is the verification key even an input? (alg:none) ----------------------------------------------------- verify_naive(alg:none, key=real public key ) -> ADMIN verify_naive(alg:none, key=empty bytes ) -> ADMIN verify_naive(alg:none, key=garbage ) -> ADMIN verify_naive(alg:none, key=the HS256 secret ) -> ADMIN

Four different keys, one of them the empty string, all ADMIN. The verification key is not an input to the decision (test_alg_none_forgery_ignores_the_key_entirely). That is the honest description of alg: none: not a weak signature, an absent one, dressed in the interface of a present one.

This is the famous bug — Tim McLean, March 2015, CVE-2015-9235 — and it dies at v1, one line. The industry patched it a decade ago. If this were all the toy had, it would be a history lesson.

6.3 Act 3: key confusion — THE AHA

The forgery, again one call:

demo.py · line 65
forged_conf = jwt.encode(GREEDY, "HS256", jwt.PUBLIC_KEY)

The attacker signs with HS256, using the server's RSA public key as the HMAC secret. Here is the token they produce, in full:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzE1MzYwMDAsInJvbGUiOiJhZG1pbiIsInN1YiI6ImFsaWNlIn0.GxRO95UmzYpcud7Wm0vg9xOwxCQ2Zf47URntQe9TdVs

and here is verify_allowlist — the careful verifier, whose allowlist contains only HS256 and RS256 — accepting it:

v2 allow ADMIN

Derive it. The three segments decode to:

header json : {"alg":"HS256","typ":"JWT"} payload json : {"exp":1731536000,"role":"admin","sub":"alice"} signing input: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzE1MzYwMDAsInJvbGUiOiJhZG1pbiIsInN1YiI6ImFsaWNlIn0 len signing input: 100

The attacker computes HMAC-SHA256(PUBLIC_KEY, signing_input). The server, holding that same PUBLIC_KEY in order to check RS256, takes the HS256 branch and computes the same thing:

attacker: HMAC(PUBLIC_KEY, signing_input) = 1b144ef79526cd8a5cb9ded69b4be0f713b0c4243665fe3b5119ed41ef53755b server : HMAC(PUBLIC_KEY, signing_input) = 1b144ef79526cd8a5cb9ded69b4be0f713b0c4243665fe3b5119ed41ef53755b sig from token = 1b144ef79526cd8a5cb9ded69b4be0f713b0c4243665fe3b5119ed41ef53755b equal: True

(1b144e… is GxRO95… in base64url; the third segment of the token above.)

Of course they're equal. Both parties ran the same function on the same inputs. No cryptography was broken and no signature was forged in the cryptographic sense — the 32 bytes in that token are a correct, honestly-computed HMAC-SHA-256 tag. The attacker's entire advantage is that the key is one the server publishes.

What did it cost? sweep.py C9:

C9 What each forgery costs, in secret bits ------------------------------------------- honest RS256 signature needs D : 509 bits of secret alg:none forgery needs : 0 bits of secret key confusion needs PUBLIC_KEY, 1456 bits of material published on purpose : 0 bits of secret HS256-only forgery needs SECRET : 224 bits of secret bytes of the accepted key-confusion token written by the attacker: 144 of 144

Every one of the 144 bytes the server accepted was written by the attacker, and producing them required zero bits of secret. The honest issuer needed a 509-bit private exponent to do the same job.

This is the shape worth carrying away: an allowlist restricts which algorithms run. It says nothing about which key they run with, and the attack lives entirely in that gap.

6.4 The load-bearing line

oneline.py is one verifier with one line toggled and nothing else changed — same allowlist, same compare_digest, same RSA check, same expiry check:

oneline.py · lines 20–34
def verify(token, key, server_alg, trust_the_token):
    header, payload, sig, signing_input = jwt.split(token)
    if trust_the_token:
        alg = header["alg"]           # LINE A: the token decides
    else:
        alg = server_alg              # LINE B: the server decides
    if alg not in ("HS256", "RS256"):
        return None
    if alg == "HS256":
        ok = hmac.compare_digest(jwt.hs256(signing_input, key), sig)
    else:
        ok = jwt.rs256_check(signing_input, sig, key)
    if not ok or payload.get("exp", 0) < NOW:
        return None
    return payload
python3 oneline.py
alg = header["alg"] honest -> user forged -> admin alg = server_alg honest -> user forged -> REJECTED

One line. The honest token is unaffected in both rows, so this is not a verifier that got stricter — it is a verifier that stopped taking instructions from its input.

6.5 Two pieces of folklore that did not reproduce

Both of these are things I expected to demonstrate and could not. They are here because a page whose value is that you can trust it more than your own first reading has to report the misses too.

Folklore 1: "block none and the attacker sends nOnE." This is repeated everywhere, and against verify_blocklist as shipped it simply does not work. sweep.py C2:

C2 The blocklist and its folklore bypass: does 'nOnE' work? ------------------------------------------------------------ alg='none' v1 as shipped -> reject v1 + normalising parser -> reject alg='nOnE' v1 as shipped -> reject v1 + normalising parser -> ADMIN alg='NONE' v1 as shipped -> reject v1 + normalising parser -> ADMIN alg=' none' v1 as shipped -> reject v1 + normalising parser -> ADMIN alg='none ' v1 as shipped -> reject v1 + normalising parser -> ADMIN

The left column is all rejections. The reason is in §5.5: the dispatcher is exact-match too, so "nOnE" falls past none, past HS256, past RS256, and off the end into return None. The bypass needs a second ingredient — a parser that canonicalises alg before dispatch while the blocklist compares the raw string:

sweep.py · lines 48–57
def verify_normalising(token, key, now=0):
    """v1, but with a parser that is liberal in what it accepts:
    canonicalise `alg` before dispatch, and blocklist the exact string.
    """
    header, payload, sig, si = jwt.split(token)
    if header["alg"] == "none":
        return None
    alg = header["alg"].strip().upper()
    if alg == "NONE":
        ok = sig == b""

So the correct statement is not "blocklists can be bypassed with case variants." It is "a blocklist and a normalising parser disagree about what string they are looking at, and the attacker picks a string they disagree on." That is a more useful thing to know, because it tells you where to look in your own code: not at the blocklist, at the two comparisons.

Folklore 2: "== leaks the signature one byte at a time." The justification usually given for hmac.compare_digest is that == short-circuits on the first differing byte, so an attacker can time their way through a signature. I tried to measure it. sweep.py C8:

C8 `==` vs hmac.compare_digest: does the timing attack reproduce? ------------------------------------------------------------------ good is full -> False, good == full -> True candidate == ns/op compare_digest 0 leading bytes match 29.70 40.79 1 leading byte matches 31.65 38.85 31 leading bytes match 31.41 40.36 32 = full match 31.57 40.42 (4000 reps x 9 trials, median of trials) spread across prefix lengths, `==`: 1.95 ns/op (6.6%) monotone in prefix length, over 10 fresh rounds: 1/10

There is no gradient. A 1-byte prefix match (31.65 ns) and a 31-byte prefix match (31.41 ns) are indistinguishable — the 31-byte one is faster, which is the wrong direction — and the whole spread across four prefix lengths is 1.95 ns, 6.6%.

A single boolean off a timing run would not be a finding, so the last line repeats the whole four-way measurement ten more times and counts how often the four medians come out in increasing order. If == leaked a per-byte signal, that would be 10/10. It is 1/10, which is about what you would get from ranking four numbers drawn from the same distribution. (The good is full -> False line is there because the comparison would be meaningless if CPython could take an identity shortcut; the full-match candidate is a distinct object.)

The reason is that CPython does not compare bytes in a Python loop. Equal-length bytes go to memcmp, which on 32 bytes is a couple of vector instructions with no observable per-byte early exit. What is measurable points the other way: hmac.compare_digest averages 40.11 ns/op here against 31.08 for ==about 9 ns/op slower, roughly 30%.

Use compare_digest anyway. The toy does, at jwt.py:160 and :203. But use it for the right reason: it costs ~9 ns and removes a whole class of question, not because this measurement supports the story.

One machine, one build These numbers came off macOS 26.5.2, arm64 Apple Silicon, Python 3.15.0a8, on a laptop with other things running. They are a measurement, not a universal claim. Different hardware, a different interpreter, a pure-Python comparison, or a signature compared across a network — any of those can change the answer, and the timing attack is entirely real in settings where the comparison is a loop. Run python3 sweep.py on your own machine and read your own C8 before you carry this anywhere.

6.6 base64 is not encryption

C10 base64 is not encryption ---------------------------- peek(honest RS256 token), no key at all: header {'alg': 'RS256', 'typ': 'JWT'} payload {'exp': 1700003600, 'role': 'user', 'sub': 'alice'} bytes of that token that are ciphertext: 0 of 186

peek (jwt.py:138-143) takes no key and does no verification, because none is needed. A signature proves nobody changed the claims; it does nothing to stop anybody reading them. Zero of the 186 bytes are ciphertext. Anything you would not print in a log does not belong in a JWT payload — it belongs in a JWE, which is a different specification, or behind an opaque session id.

6.7 The boundary condition — where the forgery vanishes

Two boundaries, both measured, and they are what let you place your own system.

Boundary 1: the attacker needs the key material byte for byte. Not "knows the public key" — knows the exact serialisation the server feeds to HMAC. sweep.py C3:

C3 Key confusion: how exact must the attacker's key bytes be? -------------------------------------------------------------- attacker uses exact bytes -> v2 allowlist ADMIN attacker uses trailing newline -> v2 allowlist reject attacker uses one digit off -> v2 allowlist reject attacker uses keys reordered -> v2 allowlist reject attacker uses spaces after colons -> v2 allowlist reject attacker uses modulus alone, decimal -> v2 allowlist reject

One row succeeds and five fail, and the failures are not near-misses that a cleverer attacker gets around — HMAC has no partial credit. A PEM file with a trailing newline and the same PEM without one are two different attacks. In the real CVE this is exactly why exploitability depends on which library you use and which key format it hands to the MAC: the attacker has to guess the server's plumbing, not its secrets. test_key_confusion_needs_the_bytes_exactly pins it.

That is also the practical mitigation you get for free from a JWKS with a kid: if the verifier looks up a parsed key object by key id and passes that to the MAC, there is no serialisation for the attacker to guess.

Boundary 2: symmetric-only deployments are immune to key confusion. sweep.py C4:

C4 Boundary: an HS256-only deployment (the verification key is secret) ----------------------------------------------------------------------- honest HS256 token, v0 -> ok attacker guesses '' -> v2 allowlist reject attacker guesses 'secret' -> v2 allowlist reject attacker tries PUBLIC_KEY -> v2 allowlist reject attacker knows the secret -> v2 allowlist ADMIN but alg:none still walks in -> v0 ADMIN v1 reject

Key confusion is specifically an asymmetric-to-symmetric downgrade. If your system has no published verification key, there is nothing to downgrade to, and verify_allowlist holds — the attacker is reduced to guessing a secret, which is the situation the cryptography was designed for. The last row is the caveat: alg: none does not care about any of this and works against every deployment, which is why it needed its own fix and got one first.

So, three questions to ask of a system you are reading:

  1. Does the verifier read alg out of the token? If no, stop worrying.
  2. If yes, does it accept both a symmetric and an asymmetric algorithm with one key parameter? If no, key confusion does not apply — but check none.
  3. If yes, is the verification key public, and can an attacker reproduce the exact bytes it is passed as? That last one is the difference between a finding and an exploit.

6.8 The tests

python3 test_jwt.py
PASS test_honest_tokens_round_trip PASS test_tampering_the_payload_is_caught PASS test_alg_none_forgery_beats_the_naive_verifier PASS test_alg_none_forgery_ignores_the_key_entirely PASS test_key_confusion_beats_the_allowlist PASS test_the_load_bearing_line_is_where_alg_comes_from PASS test_strict_must_pair_the_alg_with_the_key PASS test_key_confusion_needs_the_bytes_exactly PASS test_symmetric_deployments_are_immune_to_key_confusion PASS test_the_header_is_signed_data PASS test_exp_is_only_an_expiry_if_it_is_required PASS test_peek_needs_no_key PASS test_wrong_signatures_are_still_rejected PASS test_rsa_round_trips_and_is_not_a_stub All 14 tests PASSED

Fourteen tests, stdlib asserts, no pytest. Two of them pin the forgeries, two pin the boundaries, and test_wrong_signatures_are_still_rejected plus test_honest_tokens_round_trip are the guard rails that stop a "fix" from being return None. test_rsa_round_trips_and_is_not_a_stub asserts the toy RSA is genuinely asymmetric — that a signature made with D checks out under E, and that N really is the 511-bit product of the two hardcoded primes — because the entire argument of §6.3 collapses if RS256 here were a stub.


7. Design decisions and roads not taken

7.1 Four verifiers instead of two

The cheap version of this toy is verify_naive and verify_strict: broken and fixed, twenty lines saved, same headline. I built the middle two anyway, and they are what the page is actually for.

verify_blocklist exists because it is what the ecosystem shipped, and because a reader who only knows the alg: none story needs to watch that story end before act 3 can surprise them. verify_allowlist exists because it is the good answer — it is what a careful engineer writes, it is what a plain reading of RFC 8725 §3.2 asks for, and it is still wrong. Deleting it would leave the page arguing against a strawman.

The pattern is worth naming, because it is not specific to JWT: restricting the set of operations an attacker may request is not the same control as removing the attacker's ability to request. The allowlist does the first. Only v3 does the second.

Why is allowed=("HS256", "RS256") and not just ("RS256",)? Because ("RS256",) is v3 with extra steps — an allowlist of exactly one element is a server-chosen algorithm wearing a costume, and it would prove nothing. The interesting case is a deployment that genuinely supports two algorithms, which is the case agility exists to serve.

7.2 Why the fix is a pairing, not a keyword

The tempting summary of this page is "pass the algorithm explicitly." That is not sufficient, and I ran it. sweep.py C5:

C5 Is 'the server chooses' enough, or must the alg match the key? ------------------------------------------------------------------ v3 strict, server says RS256, key=PUBLIC -> reject v3 strict, server says HS256, key=PUBLIC -> ADMIN v3 strict, server says HS256, key=SECRET -> reject

Row 2 is verify_strict — the "fixed" verifier, never reading header["alg"] — handing back ADMIN for the same forged token. The server chose the algorithm; it just chose one that does not belong to the key it was holding.

This is why RFC 8725 §3.1 is worded the way it is:

Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations. The library MUST ensure that the "alg" or "enc" header specifies the same algorithm that is used for the cryptographic operation. Moreover, each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed.

Three requirements, not one, and the last sentence is row 2. In a real library the enforcement is structural: keys are typed objects, and an RSA public key is not accepted by an HMAC function at all. The toy passes bytes precisely so the failure is visible; §5.2.

test_strict_must_pair_the_alg_with_the_key pins both rows.

7.3 Textbook RSA rather than a stand-in

The alternative was to fake RS256 — "HMAC with a key the attacker doesn't have" — and save twelve lines. I built the real thing instead: pow(h, d, n) to sign, pow(s, e, n) == h to check, a 511-bit modulus from two hardcoded 256-bit primes.

The reason is that the entire argument of §6.3 rests on the verification key being genuinely public. With a stand-in, "the attacker knows the verification key" is an assertion the reader has to accept. With real RSA it is a consequence the reader can check: rs256_check demonstrably needs only e and n, and rs256_sign demonstrably needs d.

What it costs is a loud warning in the module docstring, because this RSA is insecure in ways that have nothing to do with the toy's point: no PKCS#1 or PSS padding, a modulus small enough to factor, and a bare digest exponentiated directly. Unpadded RSA has real attacks of its own. None of them are on this page, and none of them are needed — key confusion is indifferent to the padding scheme, which §6.7's C3 shows by locating the whole sensitivity in the serialisation of the key rather than in the mathematics.

Determinism was a happy side effect: textbook RSA has no random padding, so every signature on this page is reproducible, which PSS would have cost.

7.4 The header is data, not an instruction — once you stop obeying it

Worth checking rather than assuming, because "the attacker rewrites the header" sounds like the header is unprotected. It is not. sweep.py C6:

C6 Under every verifier, is the header data or instruction? ------------------------------------------------------------ edit typ to JWS v0 reject v2 reject relabel alg to RS256 v0 reject v2 reject

Editing one field of a valid token's header breaks its signature, at every verifier, because the signing input is the encoded header text (§5.4). An attacker cannot take a token they were legitimately issued and relabel it.

What they can do is sign a new one. That is the difference between acts 1 and 3: act 1 edits and fails, act 3 mints and succeeds. Which is why the whole attack reduces to "can the attacker compute a valid tag" — and why the answer flips the moment a published key reaches a symmetric algorithm.

7.5 Why exp is checked two different ways

verify_naive has if "exp" in payload and payload["exp"] < now; verify_strict has if payload.get("exp", 0) < now. That asymmetry is deliberate and it is a second, smaller bug on display. sweep.py C7:

C7 The expiry check: `if 'exp' in payload` vs `payload.get('exp', 0)` ---------------------------------------------------------------------- signed, no `exp` claim, v0 (`in`) -> ADMIN signed, no `exp` claim, v3 (`.get`) -> reject signed, exp in the past, v0 -> reject ten years after issue, v0 -> ADMIN

An expiry enforced only when the claim happens to be present is not an expiry — it is an expiry the issuer may opt out of, and row 4 is a token still valid ten years later.

Note what this is not: it needs the genuine signing key. It is a bug in the contract between issuer and verifier, not a forgery, which is why it is a counterfactual and not act 4. It is here because it is the most common real JWT bug after algorithm confusion, and because it makes the general point — every claim in the payload is optional until a verifier requires it. The same argument applies to aud, iss and nbf, none of which this toy implements.

test_exp_is_only_an_expiry_if_it_is_required pins all three rows.

7.6 Verifiers return the payload, not True

Every verifier here returns the claims on success and None on failure. The alternative — a boolean — is more conventional and would have made test_jwt.py shorter.

Returning the payload is what lets the demo print ADMIN instead of True, and that single word is most of the page's rhetorical work: the reader sees what the forgery bought, not that a check passed. It also removes a temptation the boolean version creates, where calling code verifies the token and then separately parses the claims out of it — two decodes of one document, which is its own small family of bugs.

The cost is a Python idiom you have to read carefully: None is falsy and so is an empty payload {}. A production interface would raise on failure rather than return a sentinel, which is what real libraries do.


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Each answer is derivable from the source, and each was verified by running it.

Question 1

Your verifier only accepts RS256 — you checked, the allowlist is ("RS256",) and nothing else. Are you safe from the forgery in §6.3?

Answer

Yes, and for an instructive reason: an allowlist of exactly one algorithm is verify_strict in disguise (§7.1). The forged token declares HS256, fails the membership test, and never reaches a key.

But notice what did the work. It was not "the list is short" — it was that the list can no longer select between two algorithms, so the attacker's alg field has stopped being an input. The moment you add a second algorithm back for a rotation, you are at verify_allowlist:

key confusion ADMIN ADMIN ADMIN reject

which is the row that matters. §7.1.

Question 2

The attacker knows your RSA public key — it's in your JWKS endpoint, that's the point. Does that mean the §6.3 forgery works against you?

Answer

Not necessarily. They need the exact bytes your verifier hands to the MAC, not the key as a mathematical object. sweep.py C3:

attacker uses exact bytes -> v2 allowlist ADMIN attacker uses trailing newline -> v2 allowlist reject attacker uses one digit off -> v2 allowlist reject attacker uses keys reordered -> v2 allowlist reject attacker uses spaces after colons -> v2 allowlist reject attacker uses modulus alone, decimal -> v2 allowlist reject

Five of six fail. PEM-with-newline and PEM-without are different attacks. If your verifier looks up a parsed key object by kid and passes that, there is no byte string to guess at all. §6.7, boundary 1.

This is a difference in exploitability, not in whether you have the bug. Fix the bug.

Question 3

You take the advice and pass the algorithm explicitly: verify_strict(token, key, "HS256"). key is your RSA public key, because that is the variable your verifier already had. Are you fixed?

Answer

No — you have written the bug out longhand.

v3 strict, server says RS256, key=PUBLIC -> reject v3 strict, server says HS256, key=PUBLIC -> ADMIN v3 strict, server says HS256, key=SECRET -> reject

Row 2 is the forged token being accepted by the "fixed" verifier. The control is not the keyword; it is that the algorithm and the key are chosen together by the party that owns both — RFC 8725 §3.1's "each key MUST be used with exactly one algorithm." §7.2.

Question 4

Your system is HS256 only, with a strong shared secret and an allowlist. Which of the two forgeries still works?

Answer

Neither works against the allowlist — but alg: none works against anything upstream of it. sweep.py C4:

attacker guesses '' -> v2 allowlist reject attacker guesses 'secret' -> v2 allowlist reject attacker tries PUBLIC_KEY -> v2 allowlist reject attacker knows the secret -> v2 allowlist ADMIN but alg:none still walks in -> v0 ADMIN v1 reject

Key confusion is an asymmetric-to-symmetric downgrade, and a symmetric-only system has nothing to downgrade to. alg: none is not a downgrade — it is the absence of a check — so it is indifferent to your key material. §6.7, boundary 2.

Question 5

You use == instead of hmac.compare_digest to compare signatures. Measure the timing attack on this toy. What do you find?

Answer

You find that it does not reproduce, on this machine:

candidate == ns/op compare_digest 0 leading bytes match 29.70 40.79 1 leading byte matches 31.65 38.85 31 leading bytes match 31.41 40.36 32 = full match 31.57 40.42 spread across prefix lengths, `==`: 1.95 ns/op (6.6%) monotone in prefix length, over 10 fresh rounds: 1/10

No gradient — 1 matching byte and 31 matching bytes are indistinguishable, and the 31-byte match is the faster of the two. Repeat the whole measurement ten times and the four medians come out in increasing order once; a real per-byte leak would give 10/10. CPython compares equal-length bytes with memcmp, which has no observable per-byte early exit at this size. The measurable difference runs the other way: compare_digest is about 9 ns/op (~30%) slower.

Use compare_digest regardless — it costs ~9 ns and closes the question — but do not repeat the folklore as though this toy demonstrated it. And note this is one machine and one interpreter build; a comparison written as a Python loop, or run on different hardware, can behave differently. §6.5.

Question 6

You are reviewing a service. Its verifier calls a library function that takes (token, key) and returns claims, with no algorithm argument. Nothing else is visible to you. What do you write in the review?

Answer

That the interface cannot be safe, independent of the implementation.

If the function is not told which algorithm to use, either it has one hardcoded — in which case the parameter is missing from the interface and the caller cannot rotate — or it reads alg from the token, which is verify_naive. There is no third option. The signature of the function is enough to file the finding; you do not need the body.

The follow-up questions are §6.7's three: does it read alg; does one key parameter serve both a symmetric and an asymmetric algorithm; and are the exact bytes of that key public. The first decides whether there is a bug, and the other two decide how quickly it gets exploited.


10. Further reading

Every link below was fetched and confirmed live when this was written.