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.
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
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 does | forged tokens it accepts | |
|---|---|---|
verify_naive | dispatches on header["alg"] | both |
verify_blocklist | …but refuses "none" | one |
verify_allowlist | …but only allows HS256 and RS256 | one |
verify_strict | never 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:
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:
alg field is covered by the signature, but you have to read it before you can check the signature. That ordering is the whole toy.exp, which is a claim inside the very document you are trying to validate. §7.5.None of this is deep, but the commentary leans on it hard.
| Concept | Where it's used here | One 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.
Before any code. A JWT is three base64url segments, and the signature covers the first two as text:
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 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.
212 lines: nine small functions and four verifiers. Read it in this order.
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.
# 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:
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.
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.
split — what the signature actually coversdef 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.
verify_naive — v0, and the line the page is aboutdef 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.
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.
verify_strict — v3, and why the keyword isn't the fixdef 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:
_header is discarded — the underscore is the point;alg arrives as an argument;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."
demo.py mints one honest RS256 token and runs four acts against all four verifiers.
python3 demo.py
Act 0 is a real signature. The claims are sub=alice, role=user, exp=1700003600 — NOW 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 — alice → alicf 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.
alg: none, and a verifier that never touches a keyThe whole forgery is one call:
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:
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.
The forgery, again one call:
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:
and here is verify_allowlist — the careful verifier, whose allowlist contains only HS256 and RS256 — accepting it:
Derive it. The three segments decode to:
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:
(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:
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.
oneline.py is one verifier with one line toggled and nothing else changed — same allowlist, same compare_digest, same RSA check, same expiry check:
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
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.
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:
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:
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:
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.
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.
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.
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:
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:
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:
alg out of the token? If no, stop worrying.none.python3 test_jwt.py
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.
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.
The tempting summary of this page is "pass the algorithm explicitly." That is not sufficient, and I ran it. sweep.py C5:
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.
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.
Worth checking rather than assuming, because "the attacker rewrites the header" sounds like the header is unprotected. It is not. sweep.py C6:
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.
exp is checked two different waysverify_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:
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.
TrueEvery 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.
kid, and no JWKS. Real deployments put a key id in the header and look the key up. That is both a mitigation (§6.7 boundary 1 — a parsed key object has no serialisation to guess) and its own attack surface: kid is another attacker-controlled header field, and it has been used for path traversal, SQL injection and directory-listing attacks when it is fed to a file read or a query. That is a second toy, not a subsection of this one.aud, iss, nbf, jti. Only exp, and only to make §7.5's point. A real verifier must check the audience — a token minted for service A being replayed at service B is a whole vulnerability class this toy does not touch.now is an argument. Real verifiers read a clock, and then need leeway for skew between issuer and verifier, which is a small tunable with its own trade-off.Authorization header, and nothing on the network. The forged token is a Python string handed to a Python function.zip: DEF in a JWS header has produced decompression-bomb denial of service in the wild. Not here.Answer before expanding. Each answer is derivable from the source, and each was verified by running it.
Your verifier only accepts RS256 — you checked, the allowlist is ("RS256",) and nothing else. Are you safe from the forgery in §6.3?
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:
which is the row that matters. §7.1.
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?
Not necessarily. They need the exact bytes your verifier hands to the MAC, not the key as a mathematical object. sweep.py C3:
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.
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?
No — you have written the bug out longhand.
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.
Your system is HS256 only, with a strong shared secret and an allowlist. Which of the two forgeries still works?
Neither works against the allowlist — but alg: none works against anything upstream of it. sweep.py C4:
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.
You use == instead of hmac.compare_digest to compare signatures. Measure the timing attack on this toy. What do you find?
You find that it does not reproduce, on this machine:
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.
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?
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.
Every link below was fetched and confirmed live when this was written.
RS256 … parameter value can be changed into HS256, and some libraries would try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret." §3.1 "Perform Algorithm Verification" is verify_strict, and its last sentence is §7.2.[McLean]. Both attacks in this toy are his. The sentence that is the whole of §6.3: "If a server is expecting a token signed with RSA, but actually receives a token signed with HMAC, it will think the public key is actually an HMAC secret key."node-jsonwebtoken instance of the key-confusion bug, and the identifier RFC 8725 cites.alg header parameter; §5.1 defines the signing input as the encoded segments joined by a period, which is §5.4 of this page; Appendix C is the base64url-without-padding routine that b64e/b64d implement.exp, and worth reading next to §7.5: the claim is defined as OPTIONAL, which is precisely why a verifier that only checks it when present has no expiry at all.SECRET does not meet); §3.3 is RSASSA-PKCS1-v1_5, which is what rs256_sign would be if it had padding; §3.6 defines "none".kid and jku/x5u header attacks this toy deliberately leaves out (§8).hmac.compare_digest — the function measured in §6.5, and the standard-library statement of what it does and does not promise. Read it before deciding what my numbers mean on your hardware.