Skip to main content
友田 陽大
Authentication & authorization
JWT
セキュリティ
Python
認証・認可
暗号

Why a JWT Signature Cannot Be Forged — Dissecting Verification Through Cryptographic Theory and PyJWT 2.13.0's Source

JWT signature verification explained from why it is safe, not how to call it. Measured avalanche effect in SHA-256 (a 1-bit flip changes 128.44 of 256 bits on average), why HMAC does not depend on collision resistance (Bellare 2006), and how PyJWT 2.13.0's api_jws.py, algorithms.py and jwks_client.py actually block alg:none and algorithm confusion — with real attack tokens thrown at the library.

Published
Reading time
31 min read
Author
友田 陽大
Share
Contents

"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.

  1. The mathematics — which property of a hash function makes tamper detection work. We measure the avalanche effect and convert computational security into a budget.
  2. The design — why a bare hash isn't enough, and why HMAC needed a two-pass nested construction.
  3. 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.

PillarWhat it guaranteesWhat breaks without itWhere it lives in your code
① Second preimage resistanceRewriting the payload always breaks the signatureTampering goes undetectedChoice of hash function (SHA-256 or better)
② Key secrecy and entropyAn attacker cannot compute a valid signatureArbitrary tokens can be forged legitimatelyHow the key is generated (entropy, not length)
③ A fixed algorithmAn attacker cannot choose how you verifyalg:none and algorithm confusion succeedThe 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.

PropertyDefinitionRelation to JWT tampering
Collision resistanceHard to find any pair (x, y) with hash(x) == hash(y)Not used directly (relevant if the issuer itself is malicious)
Preimage resistanceHard to invert a hash value back to its input — one-waynessThe key or input cannot be recovered from the signature
Second preimage resistanceGiven 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 functionCollision resistancePreimage resistanceSecond preimage resistance
SHA-1< 80 bit160 bit105–160 bit
SHA-256128 bit256 bit201–256 bit
SHA-384192 bit384 bit384 bit
SHA-512256 bit512 bit394–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 space1 GPU1,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.

TargetSearch spaceExhaustive search on 1 GPU
A second preimage for SHA-2562^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 from get_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 pinning algorithms and 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,
    ):
        ...
TierCachesDefaultExpiry
Tier 1The whole JWK Set responseEnabledTTL 300 seconds
Tier 2Individual keys by kidDisabledLRU 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 kid you have already resolved as missing for a short window (say 30 seconds) and skip the refetch
  • A rate limit on refetch: cap refresh=True calls to once every N seconds (a token bucket, for instance)
  • kid format 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:

ConcernVerify at the gatewayVerify in the application
JWKS fetching and cachingConsolidated (less load on the IdP)Spread across every service
Signature, exp, iss, audIts strengthPossible, but tends to be duplicated
Fine-grained authorization (tenancy, resource ownership)Not its jobOnly possible here
If the gateway is bypassedNothing leftStill 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=[...] to jwt.decode(). PyJWT rejects the omission with DecodeError, 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 alg from 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"*32 passes 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: False exist in production code (isolate it to test fixtures)
  • Use the result of get_unverified_header() only to read kid, never for an authorization decision
  • Validate exp / iss / aud (skipping aud lets tokens meant for another system be replayed against you)
  • Use hmac.compare_digest for MAC comparison (about 10 ns, required by RFC 7518 Section 3.2)
  • Catch jwt.PyJWTError, not jwt.InvalidTokenError. InvalidKeyError and PyJWKClientError sit outside the InvalidTokenError hierarchy, so attacks and IdP outages turn into 500s (demonstrated below)

Priority 4: harden JWKS operations

  • Fix the JWKS URI in configuration. Never trust the jku header
  • 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.

Frequently asked questions

Anyone can Base64-decode a JWT payload and read it. How is that safe?
Two different meanings of "safe" are being conflated. A JWT signature provides integrity and authenticity (the token has not been altered, and the issuer is genuine), not confidentiality (nobody can read it). Base64url is an encoding, not encryption, so the payload is readable by anyone who holds the token. That means passwords, personal data and API keys must never go in the payload. If you need to carry something that must not be read, use JWE (encrypted JWT), or better, put only an identifier in the token and look the rest up server-side.
If a collision is found in SHA-256, does that break JWTs?
Not immediately, at least for HS256 (HMAC-SHA256). Bellare proved in 2006 that HMAC is a PRF under the sole assumption that the compression function is a PRF — collision resistance of the hash function is not required. Historically this held up: HMAC-MD5 and HMAC-SHA1 did not collapse the moment practical collisions were found for MD5 and SHA-1. RS256 (RSASSA-PKCS1-v1_5) depends on the hash differently, and a collision in the signed digest can lead to forgery there. Either way the correct posture is to migrate when a break appears, not to assume you are immune.
Does using PyJWT protect me from alg:none attacks?
In my testing against PyJWT 2.13.0, all four call paths I tried were blocked. decode() makes the algorithms argument mandatory, rejects an algorithm missing from the allowlist with InvalidAlgorithmError, and even if you explicitly write algorithms=["none"], NoneAlgorithm.verify() returns False unconditionally so signature verification still fails. That holds only as long as you call PyJWT properly, though. Passing verify_signature: False, identifying a user from get_unverified_header(), or parsing headers yourself all step outside the protection.
Do I really need compare_digest if the timing leak is unmeasurable?
Yes, use it. My measurements show only a +0.1 ns difference against == on a 32-byte digest comparison, which is one thousandth of the roughly 100 ns LAN measurement floor Crosby et al. reported — invisible to a remote attacker. But RFC 7518 Section 3.2 requires verification to run in constant time, and the cost is about 10 ns per call. A spec requirement that is effectively free, and that insures you against future changes (a different language, a different runtime, a longer comparison), has no argument against it. What matters is ranking: constant-time comparison is third. First is pinning the algorithm allowlist.
What should I set the JWKS cache TTL to?
PyJWKClient defaults to 300 seconds (5 minutes) and that is a reasonable starting point. But TTL matters less than the design question of what happens when an unknown kid arrives. PyJWKClient.get_signing_key() refetches the key set once on a miss, which is what lets you follow a key rotation without waiting for the TTL to expire. The flip side is that an attacker sending random kid values triggers a fresh JWKS fetch per request. On a public API, add your own negative cache or a rate limit on the refetch.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Stuck on your auth platform or enterprise SSO design?

Auth platform & enterprise SSO (SAML/OIDC) technology selection & design review

Choosing among Cognito / Auth0 / Clerk / Supabase, SAML/OIDC federation with Azure AD, Okta, and Google, JWT (RS256) verification, and multi-tenant token design. With experience designing and running a Cognito auth foundation (RS256 JWT verification, multi-tenant authorization, multiple IdPs) on an award-winning B2B SaaS, I help you lock down the design before implementation, as a technical advisor.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading