"A JWT can't be tampered with, because it's signed." The statement is true, but far fewer engineers can explain why. And if you can't explain why, you don't know which pillar is holding the building up — which means you can't tell when you've removed it yourself. The alg:none attack and the RS256→HS256 confusion attack are not failures of cryptography. They are accidents that happen because the implementer never knew what the pillars were.
This article dissects JWT signature verification from "why is it safe" rather than "how do I call it." It works through three layers.
- The mathematics — which property of a hash function makes tamper detection work. We measure the avalanche effect and convert computational security into a budget.
- The design — why a bare hash isn't enough, and why HMAC needed a two-pass nested construction.
- The implementation — how a real library turns that theory into code. We read PyJWT 2.13.0's source (
api_jws.py,algorithms.py,jwks_client.py) and throw actual attack tokens at it.
This article concentrates on theory and implementation. For a catalogue of the attacks themselves see JWT attacks, fully mapped; for choosing between HS256 and RS256, key rotation and zero-downtime migration see HS256 vs RS256, settled by spec and measurement; for Cognito-specific verification code see Verifying AWS Cognito JWTs (RS256) correctly.
Test environment: PyJWT 2.13.0 / cryptography 50.0.0 / Python 3.13.7 / Apple Silicon. Every measurement below was produced in that environment, and the scripts are reproducible as written.
0. The conclusion: three pillars holding it up
Up front: a JWT signature holds because three things are true at the same time. Remove any one and it falls.
| Pillar | What it guarantees | What breaks without it | Where it lives in your code |
|---|---|---|---|
| ① Second preimage resistance | Rewriting the payload always breaks the signature | Tampering goes undetected | Choice of hash function (SHA-256 or better) |
| ② Key secrecy and entropy | An attacker cannot compute a valid signature | Arbitrary tokens can be forged legitimately | How the key is generated (entropy, not length) |
| ③ A fixed algorithm | An attacker cannot choose how you verify | alg:none and algorithm confusion succeed | The algorithms allowlist, decided by your app |
Most explanations cover ① and stop. But what actually breaks production is almost always ② and ③. Mathematics defends ① for you. Nothing but your own code defends ② and ③. This article covers all three, and ends with a priority ordering grounded in measurement.
1. What is actually being signed
Conclusion: the payload is not the only signed data. The header is signed too — which is why rewriting alg should register as tampering, and why the attack succeeding anyway is a logic flaw on the verifying side.
RFC 7515 Section 2 defines the input to the signature (the JWS Signing Input) precisely:
JWS Signing Input:
ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload))
In other words, the signed bytes are the dot-separated string header.payload itself. Note that it is the Base64url-encoded string, not the JSON object.
import base64
import json
def b64url(data: bytes) -> str:
"""BASE64URL per RFC 7515 Section 2: strip the '=' padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(json.dumps({"sub": "1234567890", "role": "user"}, separators=(",", ":")).encode())
signing_input = f"{header}.{payload}".encode()
# -> b'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InVzZXIifQ'
Two consequences follow.
Consequence 1: tampering with alg is itself covered by the signature. Rewrite the header and the signing input changes, so the signature no longer matches. Why, then, does the alg:none attack work? Because the attacker is not trying to make the signature match. The attack is about convincing the verifier that it need not check the signature at all. That is a defeat of control flow, not of cryptography. Section 5 shows how it is blocked at the implementation level.
Consequence 2: Base64url is not encryption. Anyone can decode the payload. What a JWT provides is not confidentiality but integrity (it has not been altered) and authenticity (the issuer is genuine). Never put passwords or personal data in a payload.
2. Why tampering is detectable — measuring the three hash properties
Conclusion: what makes tamper detection work is not collision resistance but second preimage resistance. For SHA-256 that is 256 bits — a 2^256 search space, which is unreachable.
2.1 Don't conflate the three properties
Hash functions are defined with three hardness properties, and JWTs depend on one specific one.
| Property | Definition | Relation to JWT tampering |
|---|---|---|
| Collision resistance | Hard to find any pair (x, y) with hash(x) == hash(y) | Not used directly (relevant if the issuer itself is malicious) |
| Preimage resistance | Hard to invert a hash value back to its input — one-wayness | The key or input cannot be recovered from the signature |
| Second preimage resistance | Given a known x, hard to find a different x' with hash(x) == hash(x') | ★ this is what tamper detection rests on |
Think about the attacker's position. They hold a legitimate token (the known x) and want x' — with role: "user" rewritten to role: "admin" — to produce the same signature. That is exactly the second preimage problem. They are handed a far harder problem than a collision attack, where any two inputs would do.
Here is the strength breakdown, from NIST's security strength table for approved hash functions.
| Hash function | Collision resistance | Preimage resistance | Second preimage resistance |
|---|---|---|---|
| SHA-1 | < 80 bit | 160 bit | 105–160 bit |
| SHA-256 | 128 bit | 256 bit | 201–256 bit |
| SHA-384 | 192 bit | 384 bit | 384 bit |
| SHA-512 | 256 bit | 512 bit | 394–512 bit |
A primary-source note that most articles have not caught up with: this table has long been cited as Table 1 of NIST SP 800-107 Rev. 1, but that document has been withdrawn. NIST proposed withdrawal in June 2022, decided to withdraw it after reviewing the comments received, and migrated the material to the NIST hash functions page and to documents such as SP 800-57 Part 1 (the formal withdrawal is staged, conditional on publication of the new CMVP Implementation Guidance). The numbers are unchanged on the current page, but your citation should point at the migration target, not at a withdrawn document. Second preimage resistance is given as a range because the Kelsey–Schneier attack lowers it to 256 − L(M) bits depending on the input message length M; for messages as short as a JWT you are at the 256-bit end.
2.2 Measuring the avalanche effect
To feel why searching for a second preimage is hopeless, measure the avalanche effect: the property that flipping one input bit changes roughly half the output bits. Against a real JWT signing input (83 bytes = 664 bits), I tried all 664 single-bit flips and measured how many output bits changed (the Hamming distance).
import hashlib
import statistics
def hamming(a: bytes, b: bytes) -> int:
return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
base = hashlib.sha256(signing_input).digest()
distances = []
for bit in range(len(signing_input) * 8):
flipped = bytearray(signing_input)
flipped[bit // 8] ^= 1 << (bit % 8) # flip exactly one bit
distances.append(hamming(base, hashlib.sha256(bytes(flipped)).digest()))
print(statistics.mean(distances), min(distances), max(distances))
The result:
signing input (83 bytes = 664 bits)
All 664 single-bit input flips, output bits changed out of 256:
mean 128.44 / 256 (50.17%)
min 107 max 152 std dev 7.82
theoretical value (random function): mean 128.00, std dev 8.00
tampering role from "user" to "admin": 135/256 bits changed
A mean of 128.44 bits (50.17%) with a standard deviation of 7.82 matches the theoretical figures for a perfectly random function — mean 128.00, standard deviation 8.00 — almost exactly. SHA-256's output is statistically uncorrelated with its input.
What that means in practice is that the attacker has no signal pointing toward a matching signature. Even the realistic tamper of changing role from "user" to "admin" moved 135 of 256 bits. If small input changes produced small output changes you could follow the gradient; instead every attempt resets to a coin toss. Exhaustive search is all that remains.
2.3 Converting 2^256 into a budget
Computational security is exponential, so concrete numbers help calibrate the order of magnitude. What follows is an estimate with stated assumptions, not an established fact.
Assumption: you can compute SHA-256 at 10^10 hashes/second (10 GH/s, the order of a single modern GPU).
| Search space | 1 GPU | 1,000,000 GPUs (10^16 H/s) |
|---|---|---|
| 2^128 (the collision-resistance wall) | ~1.1 × 10^21 years | ~1.1 × 10^15 years |
| 2^256 (the second-preimage wall) | ~3.7 × 10^59 years | ~3.7 × 10^53 years |
The universe is roughly 1.38 × 10^10 years old. Even with a million GPUs, the 2^128 wall alone takes about 80,000 times the age of the universe. That is the basis for calling JWT tampering practically impossible.
And here is the contrast this article most wants to land.
| Target | Search space | Exhaustive search on 1 GPU |
|---|---|---|
| A second preimage for SHA-256 | 2^256 | ~3.7 × 10^59 years |
| A key that is a human-memorizable password (estimated 40 bits) | 2^40 | ~110 seconds |
Mathematics hands you a 10^59-year wall. The moment you set the key to "password", that wall shrinks to 110 seconds. Attackers do not break the mathematics. They walk through the side door you left open. That asymmetry is exactly why RFC 8725 Section 3.5 states that human-memorizable passwords MUST NOT be used directly as the key to a keyed-MAC algorithm such as HS256.
3. Why a hash alone isn't enough — HMAC as the answer
Conclusion: a bare hash gives no authentication, because anyone can compute one. But naively prepending the key, H(key || msg), falls to length extension attacks. HMAC's two-pass nested construction answers both problems.
3.1 First failure: an unkeyed hash
Suppose signature = SHA256(header.payload). Tampering is still detected — second preimage resistance is intact. But the attacker can run the same computation, so they simply rewrite the payload and recompute the signature. You have integrity without authenticity. Hence a key is required.
3.2 Second failure: H(key || msg) and length extension
So prepend the key: SHA256(key || header.payload). This breaks too, because SHA-256 uses a Merkle–Damgård construction and emits its internal state (the chaining value) directly as output.
Without knowing key, an attacker can reconstruct the published hash value as internal state and resume the computation from there. The result is that they can compute the correct hash of H(key || msg || padding || arbitrary extra data) while still not knowing key. That is a length extension attack. In JWT terms it means appending claims to a legitimate token and producing a valid signature for the result — fatal.
(As an aside, SHA-512/256 and SHA-3 are immune, because they truncate the output or use a different construction. But JWS specifies the SHA-256 family, so the mitigation has to happen at the protocol level.)
3.3 The HMAC construction
HMAC, as defined in RFC 2104, applies the hash twice, mixing the key differently each time.
HMAC(K, text) = H( (K ⊕ opad) ‖ H( (K ⊕ ipad) ‖ text ) )
ipad = the byte 0x36 repeated B times
opad = the byte 0x5C repeated B times
B = the hash function's input block length (64 bytes for SHA-256)
The diagram above shows the two-pass structure: the inner hash processes key ⊕ ipad together with the message, and its output is then concatenated with key ⊕ opad and fed through the outer hash.
Because the outer hash wraps the inner output, the key is needed again for the outer computation even if an attacker reconstructs the inner state — so length extension does not apply. As RFC 2104 puts it, the point of the nesting is that the intermediate blocks cannot be fully chosen by the attacker.
3.4 HMAC's security does not depend on collision resistance
This is widely misunderstood. The security proof for HMAC does not require the hash function to be collision resistant.
Bellare's 2006 paper New Proofs for NMAC and HMAC: Security Without Collision-Resistance states in its abstract:
HMAC was proved by Bellare, Canetti and Krawczyk to be a PRF assuming that (1) the underlying compression function is a PRF, and (2) the iterated hash function is weakly collision-resistant. However, recent attacks show that assumption (2) is false for MD5 and SHA-1 [...]. This paper proves that HMAC is a PRF under the sole assumption that the compression function is a PRF.
History agrees. Practical collisions were found for MD5 and SHA-1, and yet HMAC-MD5 and HMAC-SHA1 did not immediately collapse. Two practical implications follow.
- HS256 would not be immediately broken by a SHA-256 collision (you should still migrate if one appears).
- RS256 (RSASSA-PKCS1-v1_5) depends differently. A collision in the signed digest can lead to forgery. "When SHA-256 becomes dangerous, HS256 and RS256 do not become dangerous at the same rate" — that asymmetry matters when you plan a migration.
3.5 Key length: the floor the spec sets as a MUST
RFC 7518 Section 3.2 is unambiguous:
A key of the same size as the hash output (for instance, 256 bits for "HS256") or larger MUST be used with this algorithm.
RFC 2104 Section 3 says much the same: fewer than L bytes is strongly discouraged, and more than L bytes does not meaningfully increase strength. Section 5.3 shows this MUST appearing verbatim in PyJWT's code.
4. Following PyJWT 2.13.0's verification flow
Conclusion: PyJWT never trusts the alg a token claims for itself. It checks that claim against the application's allowlist before it fetches the algorithm implementation. That ordering is the core of the defense.
The real decision order between calling jwt.decode() and accepting a signature lives in decode_complete() and _verify_signature() in jwt/api_jws.py.
# jwt/api_jws.py — decode_complete() (excerpt)
verify_signature = merged_options["verify_signature"]
if verify_signature and not algorithms and not isinstance(key, PyJWK):
raise DecodeError(
'It is required that you pass in a value for the "algorithms" argument when calling decode().'
)
The first gate is whether the application stated algorithms at all. Omitting it is not allowed. This is not a "safe default" but a design that makes the unsafe spelling syntactically impossible — a direct implementation of RFC 8725 Section 3.1, which requires that "libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms."
_verify_signature() is the substance.
# jwt/api_jws.py — _verify_signature() (excerpt; the ordering matters)
if algorithms is None and isinstance(key, PyJWK):
algorithms = [key.algorithm_name]
try:
alg = header["alg"]
except KeyError:
raise InvalidAlgorithmError("Algorithm not specified") from None
# ① check the alg the token claims against the application's allowlist
if not alg or (algorithms is not None and alg not in algorithms):
raise InvalidAlgorithmError("The specified alg value is not allowed")
if isinstance(key, PyJWK):
# ② if an algorithm is bound to the key, it must match that too
if alg != key.algorithm_name:
raise InvalidAlgorithmError(
f"Token algorithm {alg!r} does not match the key's "
f"algorithm {key.algorithm_name!r}"
)
alg_obj = key.Algorithm
prepared_key = key.key
else:
# ③ only now is the algorithm implementation fetched (after the allowlist)
alg_obj = self.get_algorithm_by_name(alg)
prepared_key = alg_obj.prepare_key(key) # ④ check the key has a valid shape
# ⑤ check the key length
key_length_msg = alg_obj.check_key_length(prepared_key)
if key_length_msg:
if effective_options.get("enforce_minimum_key_length", False):
raise InvalidKeyError(key_length_msg)
else:
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4)
# ⑥ finally, verify the signature
if not alg_obj.verify(signing_input, prepared_key, signature):
raise InvalidSignatureError("Signature verification failed")
Note the comment the source attaches to ②:
The PyJWK has a fixed algorithm bound at construction time. Verification must use that algorithm, not whatever the token header advertises, otherwise the caller's allow-list check above degenerates into a string compare with no behavioural effect on which algorithm actually verifies the signature.
An allowlist check that has no bearing on which algorithm actually verifies the signature is just a string comparison — and this code exists to prevent exactly that. It implements the third requirement of RFC 8725 Section 3.1: "Each key MUST be used with exactly one algorithm, and this MUST be checked." A library that leaves its design intent in the comments is a library you can trust.
5. Throwing real attacks at it — four experiments
The theory is settled. So what happens when you actually build attack tokens and feed them to PyJWT 2.13.0? No guessing: run it and find out.
5.1 alg:none — four paths, all closed
The alg:none attack is the classic: set the header's alg to none, leave the signature empty, and convince the verifier that no signature check is needed. I tried four call styles, chosen to favour the attacker.
header = b64url(json.dumps({"alg": "none", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(json.dumps({"sub": "1", "role": "admin"}, separators=(",", ":")).encode())
forged = f"{header}.{payload}." # empty signature segment
=== Experiment 2: alg:none attack ===
decode(token, secret, algorithms=["HS256"])
-> InvalidAlgorithmError: The specified alg value is not allowed
decode(token, secret, algorithms=["none"])
-> InvalidKeyError: When alg = "none", key value must be None.
decode(token, "", algorithms=["none"]) <- most favourable to the attacker
-> InvalidSignatureError: Signature verification failed
decode(token, secret) <- algorithms omitted
-> DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode().
All four failed. The third is the interesting one. Even in the worst combination — a developer mistakenly writing algorithms=["none"] and passing an empty-string key — it still stops with InvalidSignatureError. algorithms.py explains why.
# jwt/algorithms.py
class NoneAlgorithm(Algorithm):
def prepare_key(self, key: str | None) -> None:
if key == "":
key = None
if key is not None:
raise InvalidKeyError('When alg = "none", key value must be None.')
return key
def sign(self, msg: bytes, key: None) -> bytes:
return b""
def verify(self, msg: bytes, key: None, sig: bytes) -> bool:
return False # <- unconditionally False
NoneAlgorithm.verify() returns False without looking at a single argument. Even if operational error breaches the first wall (the allowlist), the second wall always stops it. This is textbook defense in depth. RFC 7518 Section 3.6 requires that implementations supporting Unsecured JWSs "MUST NOT accept such objects as valid unless the application specifies that it is acceptable" and "MUST NOT accept Unsecured JWSs by default" — PyJWT satisfies that structurally, not merely by default.
One caveat: all of this assumes you call PyJWT properly. Pass
options={"verify_signature": False}, identify a user fromget_unverified_header(), or parse headers yourself, and the protection is gone. A library cannot defend against how it is called.
5.2 RS256 → HS256 algorithm confusion
The subtler attack is algorithm confusion (key confusion). When a server expects RS256, the attacker uses the public key — which anyone can obtain — as the HMAC shared secret and signs with alg:HS256. If the server reads alg from the token and obediently follows it, HMAC verification against that same public key succeeds.
# attacker side: use the public key PEM as the HMAC secret
pub_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
signing_input = f"{header}.{payload}".encode() # header says alg:HS256
sig = hmac.new(pub_pem, signing_input, hashlib.sha256).digest()
forged = f"{header}.{payload}.{b64url(sig)}"
=== Experiment 3: RS256 -> HS256 algorithm confusion ===
verified as raw HMAC-SHA256: True (structurally, the forgery succeeds)
server pins alg: algorithms=["RS256"]
-> InvalidAlgorithmError: The specified alg value is not allowed
server allows both: algorithms=["RS256","HS256"] <- the vulnerable setting
-> InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
The first line matters. As raw HMAC, the forgery succeeds (True). The attack's structure is entirely intact; the only thing stopping it is the verifier's judgement.
Now look at the third line. Even in the vulnerable configuration where a developer allowed both algorithms=["RS256", "HS256"], PyJWT still stopped it. The reason is HMACAlgorithm.prepare_key().
# jwt/algorithms.py — HMACAlgorithm.prepare_key()
def prepare_key(self, key: str | bytes) -> bytes:
key_bytes = force_bytes(key)
if len(key_bytes) == 0:
raise InvalidKeyError("HMAC key must not be empty.")
if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
raise InvalidKeyError(
"The specified key is an asymmetric key or x509 certificate and"
" should not be used as an HMAC secret."
)
# Defense against algorithm-confusion attacks: an attacker with
# control over the token header can force this code path by setting
# alg=HS*, and HMACAlgorithm is the only algorithm that accepts
# arbitrary bytes as a valid secret. [...]
stripped = key_bytes.lstrip()
if stripped.startswith(b"{"):
jwk_obj = json.loads(key_bytes) # exceptions are swallowed and treated as None
if isinstance(jwk_obj, dict) and "kty" in jwk_obj:
raise InvalidKeyError(
"The specified key looks like a JWK and should not be "
"used directly as an HMAC secret. ..."
)
return key_bytes
The source comment states the threat model exactly: HMAC is the only algorithm that accepts arbitrary bytes as a valid secret, while other algorithms naturally reject input that isn't key-shaped. That is why an explicit guard is needed here and nowhere else. Alongside PEM and SSH key formats, JSON that starts with { and looks like a JWK (has a kty) is rejected too.
But do not treat this as your front line. The guard only fires when the key has a recognisable shape such as PEM. It does nothing on a path that hands HMAC a public key pulled from a JWKS as raw numeric bytes. The front line is always pinning algorithms; prepare_key is the last-resort backstop.
5.3 The key-length check — a spec MUST becomes code
# jwt/algorithms.py — HMACAlgorithm.check_key_length()
def check_key_length(self, key: bytes) -> str | None:
min_length = self.hash_alg().digest_size
if len(key) < min_length:
return (
f"The HMAC key is {len(key)} bytes long, which is below "
f"the minimum recommended length of {min_length} bytes for "
f"{self.hash_alg().name.upper()}. "
f"See RFC 7518 Section 3.2."
)
return None
The RFC 7518 Section 3.2 reference is embedded directly in the error message. Measured behaviour:
=== Experiment 4: detecting weak keys (check_key_length) ===
key='password' (8 bytes)
-> InsecureKeyLengthWarning: The HMAC key is 8 bytes long, which is below
the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
key=token_hex(8) (16 bytes)
-> InsecureKeyLengthWarning: ... 16 bytes ...
key=token_bytes(32).hex() (64 bytes)
-> no warning
with enforce_minimum_key_length=True:
-> InvalidKeyError: The HMAC key is 8 bytes long, ...
By default this is a warning only (InsecureKeyLengthWarning) and processing continues; pass options={"enforce_minimum_key_length": True} and it stops with InvalidKeyError. The permissive default exists to avoid breaking existing tokens, but new projects should enable the option from day one. Note also that the check runs in both encode() and decode(), so a single round trip can emit the warning twice.
5.4 The important limitation: the length check does not measure entropy
This is the point I most want to flag. check_key_length looks at len(key) — byte length, nothing else. It does not measure entropy. Measured:
key='aaaaaaaaaaaaaaaaaaaa...' len=32B -> no warning, verification succeeded
key='passwordpasswordpass...' len=32B -> no warning, verification succeeded
key='a80dffcee7a242fe8608...' len=32B -> no warning, verification succeeded
Both "a" * 32 and "password" * 4 sail through without a single warning, even with enforce_minimum_key_length=True enabled. Their effective entropy is a handful of bits, which per the table in Section 2.3 means exhaustive search in seconds.
So PyJWT's key-length check defends against an accidentally short key, not against a weak one. What RFC 8725 Section 3.5 demands is entropy, not length. Always generate keys from a CSPRNG.
import secrets
# correct: 256 bits of real entropy
SECRET_KEY = secrets.token_urlsafe(32) # 43 characters / 256 bits
# wrong: long enough, but no entropy
SECRET_KEY = "my-super-secret-key-for-production"
5.5 hmac.compare_digest — testing the received wisdom
Conclusion: always use constant-time comparison. But measured, the timing a naive == leaks on JWT's 32-byte comparison sits far below the measurement floor. Presenting compare_digest as "the thing keeping you alive" misranks your priorities.
PyJWT's HMAC verification is one line.
# jwt/algorithms.py — HMACAlgorithm.verify()
def verify(self, msg: bytes, key: bytes, sig: bytes) -> bool:
return hmac.compare_digest(sig, self.sign(msg, key))
The reasoning goes like this. If a naive == compares byte by byte from the front and short-circuits on the first mismatch, an attacker can infer from the elapsed time how many bytes matched, and build a valid signature one byte at a time. RFC 7518 Section 3.2 likewise requires verification to run in constant time.
So how much actually leaks? I measured the execution time for a mismatch in the first byte versus a mismatch in the last byte, varying the size of the compared data.
By size: timing difference between a head mismatch and a tail mismatch (ns)
size | == head == tail diff | cd head cd tail diff
32 | 21.1 21.2 +0.1 | 29.6 29.2 -0.4
256 | 21.4 25.6 +4.2 | 98.4 100.0 +1.6
4096 | 22.1 103.6 +81.5 | 1128.9 1122.7 -6.2
65536 | 18.3 1184.7 +1166.4 | 18614.6 19053.3 +438.7
1048576 | 19.2 17971.0 +17951.9 | 300413.8 301636.2 +1222.5
Two things stand out.
① The short-circuit mechanism is real. With a head mismatch, == takes about 20 ns regardless of size (it bails immediately); with a tail mismatch it grows in proportion to size, leaking +17.9 µs at 1 MB. compare_digest, by contrast, scales with size in both cases and shows no head/tail difference. Exactly as designed.
② But at the 32 bytes a JWT deals in, that leak is only +0.1 ns. Compare that against the measurement accuracy Crosby et al. reported in Opportunities and Limits of Remote Timing Attacks (ACM TISSEC, 2009) — about 100 ns over a local network, and 15–100 µs across the Internet — and even the best LAN case is a thousand times coarser. A remote attacker cannot observe this signal.
The correct conclusion, then:
- Always use
compare_digest. RFC 7518 Section 3.2 requires it, it costs about 10 ns per call, and it insures you against future implementation changes, different runtimes, and longer comparison targets. Use it whenever you write MAC verification by hand. - But don't present it as the top-priority control. For a 32-byte HMAC comparison,
==is not a realistic attack surface. The same effort spent on pinningalgorithmsand on key entropy reduces your actual risk by far more.
Plenty of articles enumerate "security measures." Enumeration erases priority. Measurement restores it.
6. JWKS architecture — reading operational design out of PyJWKClient
Conclusion: the design question in JWKS is not cache TTL but what happens when an unknown kid arrives. PyJWKClient's implementation shows the answer — and also shows where you need to add your own reinforcement on a public API.
With asymmetric keys such as RS256, verifiers need to obtain the public key. The standard arrangement is to serve a JWK Set (defined by RFC 7517) from an endpoint and select the right key by the token header's kid (Key ID). The design decisions are concentrated in jwt/jwks_client.py.
6.1 Two-tier caching
class PyJWKClient:
def __init__(
self,
uri: str,
cache_keys: bool = False, # tier 2: per-kid LRU (off by default)
max_cached_keys: int = 16,
cache_jwk_set: bool = True, # tier 1: the whole JWK Set (on by default)
lifespan: float = 300, # TTL 5 minutes
headers: dict[str, Any] | None = None,
timeout: float = 30,
ssl_context: SSLContext | None = None,
):
...
| Tier | Caches | Default | Expiry |
|---|---|---|---|
| Tier 1 | The whole JWK Set response | Enabled | TTL 300 seconds |
| Tier 2 | Individual keys by kid | Disabled | LRU only (no time-based expiry) |
Tier 2 being off by default is the right call. lru_cache has no time-based expiry, so enabling it keeps revoked or rotated keys alive until the size limit evicts them. The docstring says as much: "no time-based expiration." If you enable it for performance, enable it knowing that.
6.2 On an unknown kid, refetch exactly once
This is the core of how key rotation is followed.
def get_signing_key(self, kid: str) -> PyJWK:
signing_keys = self.get_signing_keys()
signing_key = self.match_kid(signing_keys, kid)
if not signing_key:
# not in cache: refetch the JWK Set and retry exactly once
signing_keys = self.get_signing_keys(refresh=True)
signing_key = self.match_kid(signing_keys, kid)
if not signing_key:
raise PyJWKClientError(f'Unable to find a signing key that matches: "{kid}"')
return signing_key
This lets you follow a new key immediately after the IdP rotates, without waiting up to 5 minutes for the TTL to expire. Those three lines are why JWKS-based rotation can be done with zero downtime.
But there is a flip side. An unknown kid always triggers a network fetch. If an attacker sends a flood of tokens carrying random kid values, each request produces an HTTP request to the JWKS endpoint. Your own service hammers the IdP, or you hit the IdP's rate limit and legitimate authentication goes down as collateral — an amplification path for denial of service. PyJWT does not rate-limit this refetch. On a public API, add one of the following yourself:
- A negative cache: remember a
kidyou have already resolved as missing for a short window (say 30 seconds) and skip the refetch - A rate limit on refetch: cap
refresh=Truecalls to once every N seconds (a token bucket, for instance) kidformat validation: reject values that don't match the shape (length, character set) your IdP issues, before fetching
6.3 Don't wipe the cache on failure
An unglamorous but important decision:
def fetch_data(self) -> Any:
try:
...
jwk_set = json.load(response)
except (URLError, TimeoutError) as e:
...
raise PyJWKClientConnectionError(...) from e
# Only update the cache on a successful fetch. Writing in a
# `finally` block with `jwk_set=None` on error clears any
# previously-cached JWKS, turning a transient outage into a cache
# wipe that breaks legitimate auth.
if self.jwk_set_cache is not None:
self.jwk_set_cache.put(jwk_set)
return jwk_set
The cache is updated only on a successful fetch. Write that in a finally block instead and a brief IdP hiccup turns into a cache wipe — an amplification incident where "the IdP was down for a few seconds" becomes "every user's authentication failed." For availability, the rule with caches over external dependencies is to keep the stale value on failure.
6.4 Defending against a jku-derived URL
There is a scheme check at the top of the constructor.
# urllib's default OpenerDirector also handles file://, ftp://, and
# data: URIs. Reject anything that isn't http(s) eagerly so a caller
# passing an attacker-influenced URL (e.g. taken from a `jku` token
# header) can't read local files or reach other unintended schemes.
scheme = urlparse(uri).scheme.lower()
if scheme not in ("http", "https"):
raise PyJWKClientError(...)
urllib handles file:// by default, so passing a jku-derived URL straight through turns into a local file read. This too is a backstop, though — the rule that you must not trust jku as given still stands. Fix the JWKS URI in configuration, or validate it against a strict allowlist.
6.5 Where to verify — offloading to an API gateway, and the division of responsibility
JWKS verification can be offloaded to an API gateway (AWS API Gateway JWT Authorizer, Envoy/Istio, Kong, and so on). The decision axes:
| Concern | Verify at the gateway | Verify in the application |
|---|---|---|
| JWKS fetching and caching | Consolidated (less load on the IdP) | Spread across every service |
Signature, exp, iss, aud | Its strength | Possible, but tends to be duplicated |
| Fine-grained authorization (tenancy, resource ownership) | Not its job | Only possible here |
| If the gateway is bypassed | Nothing left | Still protected |
The recommendation is both layers. Let the gateway be the common gatekeeper for signature, exp, iss and aud, and let the application concentrate on authorization — may this user act on this resource? But if any path can reach the application directly (inside the VPC, service-to-service calls, a debug port), do not drop signature verification in the application. "The gateway must be checking it" is an assumption that one network change can invalidate.
Cognito-specific details such as token_use validation and clock skew are covered in Verifying AWS Cognito JWTs (RS256) correctly.
7. A prioritized checklist
Enumerating controls erases priority. Here they are in the order this article's measurements support. They work top-down.
Priority 1: pin the algorithm (drop this and nothing else matters)
- Always pass
algorithms=[...]tojwt.decode(). PyJWT rejects the omission withDecodeError, but libraries in other languages let it through silently - Keep the allowlist to one element. Allowing several, as in
["RS256", "HS256"], is the doorway to confusion attacks (demonstrated in Section 5.2) - Use one key with exactly one algorithm (RFC 8725 Section 3.1). PyJWK binds this at construction time
- Never write your own code that reads
algfrom the token and branches on it
Priority 2: guarantee key entropy
- Generate HS256 keys from a CSPRNG such as
secrets.token_urlsafe(32). Entropy, not length (demonstrated in Section 5.4:"a"*32passes without a warning) - Never use a human-chosen string, password or environment name as a key (RFC 8725 Section 3.5, MUST NOT)
- Make
options={"enforce_minimum_key_length": True}the default on new projects - Keep keys in environment variables or a secrets manager, never in source
Priority 3: make verification impossible to skip
- Never let
verify_signature: Falseexist in production code (isolate it to test fixtures) - Use the result of
get_unverified_header()only to readkid, never for an authorization decision - Validate
exp/iss/aud(skippingaudlets tokens meant for another system be replayed against you) - Use
hmac.compare_digestfor MAC comparison (about 10 ns, required by RFC 7518 Section 3.2) - Catch
jwt.PyJWTError, notjwt.InvalidTokenError.InvalidKeyErrorandPyJWKClientErrorsit outside theInvalidTokenErrorhierarchy, so attacks and IdP outages turn into 500s (demonstrated below)
Priority 4: harden JWKS operations
- Fix the JWKS URI in configuration. Never trust the
jkuheader - Put a negative cache or rate limit in front of refetches triggered by an unknown
kid(the DoS surface in Section 6.2) - If you use
cache_keys=True, use it knowing there is no time-based expiry - Don't discard a stale cache when a JWKS fetch fails
- Even when you offload to a gateway, keep application-side verification if the application can be reached directly
The trap I found by measuring: except jwt.InvalidTokenError does not catch confusion attacks
The standard error-handling idiom looks like this:
try:
claims = jwt.decode(token, key, algorithms=["RS256"])
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
This does not catch the algorithm confusion attack from Section 5.2. I threw one at it to confirm.
=== throwing a confusion-attack token at the standard 401 handler ===
!! not caught by InvalidTokenError, becomes a 500:
InvalidKeyError -> The specified key is an asymmetric key or x509 certificate
and should not be used as an HMAC secret.
The cause is PyJWT's exception hierarchy. Measured, the inheritance looks like this — and InvalidKeyError does not inherit from InvalidTokenError.
PyJWTError <- Exception
├─ InvalidTokenError <- PyJWTError
│ ├─ DecodeError
│ │ └─ InvalidSignatureError
│ ├─ InvalidAlgorithmError
│ ├─ ExpiredSignatureError
│ ├─ InvalidAudienceError / InvalidIssuerError / MissingRequiredClaimError ...
├─ InvalidKeyError <- PyJWTError * NOT under InvalidTokenError
├─ PyJWKClientError <- PyJWTError * same
│ └─ PyJWKClientConnectionError
└─ PyJWKError / PyJWKSetError <- PyJWTError * same
Two concrete harms follow. ① Attacks show up as 500s. A confusion attack produces a stack trace and is recorded as a server error rather than a 401. Attack-detection alerts usually watch for a rise in authentication failures, so a 500 slips past the monitoring. ② JWKS outages show up as 500s. PyJWKClientConnectionError is outside the hierarchy too, so a transient IdP problem surfaces to users as an uncaught exception.
The fix is simple: catch the base PyJWTError, and branch by type where it matters.
try:
claims = jwt.decode(token, key, algorithms=["RS256"])
except jwt.PyJWKClientError as e:
# can't fetch the key = our problem. 503, not 401
logger.error("jwks_unavailable", exc_info=e)
raise HTTPException(status_code=503, detail="Auth temporarily unavailable")
except jwt.InvalidKeyError as e:
# malformed key = misconfiguration or attack. always record it as a warning
logger.warning("jwt_key_rejected", extra={"reason": str(e)})
raise HTTPException(status_code=401, detail="Invalid token")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid token")
The point is to put except jwt.PyJWTError last as the backstop. Existing code that only watches InvalidTokenError is worth grepping for right now.
Pin it with regression tests
Checklists rot. Bake the attack tokens into your test suite.
import base64
import hashlib
import hmac
import json
import jwt
import pytest
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def _segment(obj: dict[str, object]) -> str:
return b64url(json.dumps(obj, separators=(",", ":")).encode())
def test_alg_none_is_rejected() -> None:
"""Pin that an alg:none token is never accepted."""
forged = f"{_segment({'alg': 'none', 'typ': 'JWT'})}.{_segment({'role': 'admin'})}."
with pytest.raises(jwt.PyJWTError):
verify_token(forged) # the application's verification function
def test_algorithm_confusion_is_rejected(rsa_public_pem: bytes) -> None:
"""Pin that an HS256 token signed with the public key is never accepted."""
header = _segment({"alg": "HS256", "typ": "JWT"})
payload = _segment({"role": "admin"})
signature = hmac.new(rsa_public_pem, f"{header}.{payload}".encode(), hashlib.sha256).digest()
with pytest.raises(jwt.PyJWTError):
verify_token(f"{header}.{payload}.{b64url(signature)}")
jwt.PyJWTError is the true base of PyJWT's exception hierarchy, so it catches InvalidAlgorithmError, InvalidKeyError and InvalidSignatureError alike. Pin that it fails, not how it fails — that is what makes a test survive library upgrades (if PyJWT changes which exception it raises, the test still holds).
8. Summary
We dissected JWT signature verification in three layers.
In the mathematics, we confirmed that tamper detection rests on second preimage resistance (256 bits for SHA-256) rather than collision resistance, and measured the avalanche effect: a one-bit flip changes 128.44 of 256 bits (50.17%) on average with a standard deviation of 7.82 — essentially the theoretical values for a random function. No gradient information is left for an attacker.
In the design, we saw that a bare hash gives no authentication, that the naive H(key‖msg) falls to length extension, and that HMAC therefore adopted a two-pass nested construction. And HMAC's security proof does not depend on the hash's collision resistance (Bellare 2006) — an asymmetry meaning that if SHA-256 ever weakens, HS256 and RS256 will not become dangerous at the same rate.
In the implementation, we threw real attack tokens at PyJWT 2.13.0. alg:none failed through all four paths, and we confirmed the defense in depth of NoneAlgorithm.verify() returning False unconditionally. RS256→HS256 confusion was stopped by prepare_key's PEM detection even though the forgery itself succeeds (True as raw HMAC).
The implementation layer also produced a trap I had not anticipated. The standard except jwt.InvalidTokenError → 401 handler does not catch the algorithm confusion attack, because InvalidKeyError sits outside the InvalidTokenError hierarchy (directly under PyJWTError). The attack is therefore recorded as a 500 rather than a 401, and slips past monitoring that watches for a rise in authentication failures. JWKS outages (PyJWKClientError) become uncaught exceptions for the same reason. Catch jwt.PyJWTError instead.
And the most practically useful finding is a correction to the received priority ordering. compare_digest leaks only +0.1 ns over == on a 32-byte comparison — one thousandth of the 100 ns LAN measurement floor Crosby et al. reported. Meanwhile the key-length check does not look at entropy, so "a" * 32 passes without a warning. Use every control you should use, but the order in which to spend limited attention is ① pin the algorithm → ② key entropy → ③ constant-time comparison.
What breaks a JWT is not progress in mathematics. It is an implementation that removes a pillar without ever knowing it was one. I hope this article has shown where those pillars are.
Reproducing the measurements
Everything measured here is reproducible. Run pip install "pyjwt[crypto]==2.13.0" and execute the scripts in Section 2.2 (avalanche effect) and Sections 5.1–5.5 (attack experiments and timing) as written. The exact numbers depend on your environment (CPU, Python build, OpenSSL version), but the trends should reproduce: the avalanche effect matching the theoretical values, every attack failing, and the == timing difference sitting below the measurement floor at 32 bytes while becoming obvious at 1 MB. Don't take the numbers on faith — measure them yourself. That is rather the point of this article.