# 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: 2026-08-14
- Author: 友田 陽大
- Tags: JWT, セキュリティ, Python, 認証・認可, 暗号
- URL: https://tomodahinata.com/en/blog/jwt-signature-verification-cryptography-pyjwt-source-code-analysis-guide
- Category: Authentication & authorization
- Pillar guide: https://tomodahinata.com/en/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## Key points

- Tamper detection in a JWT rests on second preimage resistance, not on encryption. Flipping a single bit of the payload changes 128.44 of 256 output bits on average (50.17%) — measured — so an attacker has no gradient to follow toward a matching signature
- A bare hash is not enough, because anyone can compute one. The naive H(key||msg) construction falls to length extension attacks, which is why HMAC nests the hash twice. And HMAC's security proof does not require the hash to be collision resistant (Bellare 2006)
- I threw alg:none at PyJWT 2.13.0 through four different call paths and every one failed. NoneAlgorithm.verify() returns False unconditionally, so even an operator who wrongly allowlists none still cannot get a signature accepted
- RS256→HS256 confusion, measured too. The forgery itself succeeds as raw HMAC, but PyJWT's prepare_key detects the PEM shape and raises InvalidKeyError. That is a backstop, not the front line — pinning the algorithm still is
- compare_digest leaks only +0.1 ns over == on a 32-byte compare, one thousandth of the 100 ns LAN measurement floor reported by Crosby et al. Use it — RFC 7518 requires it — but the real risk lives in the algorithm allowlist and key entropy

---

"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](/blog/jwt-attack-techniques-alg-none-key-confusion-secret-cracking-guide); for choosing between HS256 and RS256, key rotation and zero-downtime migration see [HS256 vs RS256, settled by spec and measurement](/blog/jwt-hs256-vs-rs256-signing-algorithm-selection-key-rotation-guide); for Cognito-specific verification code see [Verifying AWS Cognito JWTs (RS256) correctly](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide).

> **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:

```text
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.

```python
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](https://csrc.nist.gov/projects/hash-functions) 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).

```python
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:

```text
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**.

```text
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`.

```python
# 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.

```python
# 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.

```python
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
```

```text
=== 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.

```python
# 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.

```python
# 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)}"
```

```text
=== 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()`.

```python
# 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

```python
# 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:

```text
=== 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:

```text
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.

```python
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.

```python
# 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.

```text
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

```python
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.

```python
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:

```python
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.

```python
# 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](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide).

---

## 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:

```python
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.

```text
=== 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`**.

```text
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.**

```python
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.**

```python
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.
