raise AlgorithmMismatchError("header is missing the alg claim")
Files: ~/hardened_jwt.py (hardened verifier) and ~/test_hardened_jwt.py (23 tests).
The vulnerable verifier takes alg from the token header. An attacker grabs the server's RSA public key (public knowledge), signs {"sub":"admin","role":"admin"} with HS256 using the public-key PEM bytes as the HMAC secret, and sets alg: HS256. The buggy server sees HS256 and uses its public key bytes as an HMAC secret — the HMAC matches, and the forged admin token is accepted. A vulnerable reference verifier is included in the test file and demonstrates exactly that (vulnerable_verify(forged, pubkey) returns the admin payload).
HardenedJWT.verify(token, key, expected_alg)The expected_alg is pinned by the caller and is the only algorithm ever accepted — the header's alg must match it byte-for-byte. Combined with strict key-type/alg binding:
# hardened_jwt.py — algorithm pinning (excerpt)
if "alg" not in header:
raise AlgorithmMismatchError("header is missing the alg claim")
alg = header["alg"]
if not isinstance(alg, str):
raise AlgorithmMismatchError(...)
if alg == "none":
raise NoneAlgorithmError("alg=none is never accepted") # defense vs alg=none
if alg not in KNOWN_ALGS:
raise AlgorithmMismatchError(...)
if alg != expected_alg:
raise AlgorithmMismatchError(
f"header alg {alg!r} does not match the pinned algorithm {expected_alg!r}")
# Key-type <-> algorithm binding (excerpt)
if alg in SYMMETRIC_ALGS: # HS256/384/512
# str/bytes secret ONLY — an RSA/EC key object is refused
if not isinstance(key, (str, bytes)):
raise KeyTypeError("...refusing to use an asymmetric key object as an HMAC secret")
if not allow_pem_hmac_secret and b"-----BEGIN" in key_bytes:
raise KeyTypeError("refusing to use PEM-encoded key material as an HMAC secret "
"(RS256->HS256 confusion defense)")
else: # RS*/PS*/ES*/EdDSA
# raw bytes/str are NEVER accepted as a public key
if isinstance(key, (str, bytes)):
raise KeyTypeError("...raw bytes/str are never accepted as the verification key")
if hasattr(key, "private_bytes"): # private key refused
raise KeyTypeError("...supply the public key")
_check_public_key_family(key, alg) # RSA vs EC vs Ed25519 + curve check
This blocks the attack three independent ways: the forged token says HS256 while the server pins RS256 → AlgorithmMismatchError; if a server were misconfigured to pin HS256, an RSA key object → KeyTypeError, and the PEM bytes as a "secret" → KeyTypeError (PEM-as-HMAC defense). Signatures are genuinely verified (cryptography: RSA PKCS1v15/PSS, ECDSA raw r‖s, Ed25519, constant-time HMAC).
Embedded-JWK confusion: any jwk / jku / x5u / x5c header → EmbeddedKeyError. The verification key is always the caller's, never the token's.
Duplicate alg headers: JSON is parsed with a strict object_pairs_hook that rejects any repeated key, so {"alg":"none","alg":"RS256"} (which Python's default parser collapses to last-wins RS256, other parsers to first-wins) → DuplicateClaimError. Duplicate payload keys are rejected too, and the token must have exactly 3 non-empty base64url segments.
def _reject_duplicate_keys(pairs):
obj = {}
for key, value in pairs:
if key in obj:
raise DuplicateClaimError(f"duplicate JSON key {key!r} in token segment")
obj[key] = value
return obj
# ...used as: json.loads(text, object_pairs_hook=_reject_duplicate_keys)
Caller usage: HardenedJWT.verify(token, public_key, "RS256") or HardenedJWT.verify_pem(token, public_pem, "RS256") for asymmetric, HardenedJWT.verify(token, secret, "HS256") for symmetric. Never derive the algorithm from the token.
`python3 test_hardened_jwt.py` → **23/23 PASS** (also `pytest`: 23 passed). Core exploit test (`test_forged_hs256_signed_with_public_key_bytes_fails`) proves a token signed with the public-key bytes fails under every presentation (RS256-pinned → `AlgorithmMismatchError`; HS256-pinned with key object → `KeyTypeError`; HS256-pinned with PEM bytes → `KeyTypeError`).
Live demonstration:
```
Attacker builds HS256 token with signature = HMAC(RSA public key bytes)
vulnerable_verify(forged, pubkey) -> {'sub': 'admin', 'role': 'admin', 'iss': 'attacker'} # BUG
Hardened verifier (pinned RS256, public key object):
REJECTED: verify(forged, RSA pubkey obj, 'RS256')
-> AlgorithmMismatchError: header alg 'HS256' does not match the pinned algorithm 'RS256'
REJECTED: verify(forged, RSA pubkey obj, 'HS256')
-> KeyTypeError: ... refusing to use an asymmetric key object as an HMAC secret
REJECTED: verify(forged, PEM bytes, 'HS256')
-> KeyTypeError: refusing to use PEM-encoded key material as an HMAC secret
REJECTED: alg=none -> NoneAlgorithmError: alg=none is never accepted
REJECTED: embedded jwk -> EmbeddedKeyError: ... 'jwk'
REJECTED: duplicate alg -> DuplicateClaimError: duplicate JSON key 'alg' in token segment
Legitimate RS256 token still verifies: {'sub': 'alice', 'role': 'user'}
```
Edge cases covered: valid RS256/HS256/ES256/PS256 round-trips (incl. PEM loading); alg=none; missing/unknown/fuzzy algs (`RS257`, `rs256`, `"RS256 "`, `123`); pinned-alg mismatch both directions; key-type mismatch both directions (HS+key-object, RS+string secret, RS+raw bytes); private key refused; key-family/curve mismatch (ES256 vs RSA key); embedded `jwk`/`jku`/`x5u`/`x5c`; duplicate `alg` in all three orderings; duplicate payload key; tampered signature and tampered payload; wrong segment counts (`1/2/4` segments, `".."`, `""`); invalid base64url chars; non-object header/payload. Bonus: Python's default parser collapses `{"alg":"none","alg":"RS256"}` to last-wins `RS256` — confirming the ambiguity the duplicate-key defense kills — and PyJWT 2.10.1 with `algorithms=["RS256"]` also rejects the forgery (`InvalidAlgorithmError`), while its own PEM-as-HMAC check raises `InvalidKeyError` for the HS256 misconfiguration (same defense-in-depth as this library).{"model": "deepseek-v4-flash", "problem_class": "python-jwt-alg-confusion-rs256-hs256", "result": "passed", "tests": 23}Files: ~/hardened_jwt.py (hardened verifier) and ~/test_hardened_jwt.py (23 tests).
The vulnerable verifier takes alg from the token header. An attacker grabs the server's RSA public key (public knowledge), signs {"sub":"admin","role":"admin"} with HS256 using the public-key PEM bytes as the HMAC secret, and sets alg: HS256. The buggy server sees HS256 and uses its public key bytes as an HMAC secret — the HMAC matches, and the forged admin token is accepted. A vulnerable reference verifier is included in the test file and demonstrates exactly that (vulnerable_verify(forged, pubkey) returns the admin payload).
HardenedJWT.verify(token, key, expected_alg)The expected_alg is pinned by the caller and is the only algorithm ever accepted — the header's alg must match it byte-for-byte. Combined with strict key-type/alg binding:
# hardened_jwt.py — algorithm pinning (excerpt)
if "alg" not in header:
raise AlgorithmMismatchError("header is missing the alg claim")
alg = header["alg"]
if not isinstance(alg, str):
raise AlgorithmMismatchError(...)
if alg == "none":
raise NoneAlgorithmError("alg=none is never accepted") # defense vs alg=none
if alg not in KNOWN_ALGS:
raise AlgorithmMismatchError(...)
if alg != expected_alg:
raise AlgorithmMismatchError(
f"header alg {alg!r} does not match the pinned algorithm {expected_alg!r}")
# Key-type <-> algorithm binding (excerpt)
if alg in SYMMETRIC_ALGS: # HS256/384/512
# str/bytes secret ONLY — an RSA/EC key object is refused
if not isinstance(key, (str, bytes)):
raise KeyTypeError("...refusing to use an asymmetric key object as an HMAC secret")
if not allow_pem_hmac_secret and b"-----BEGIN" in key_bytes:
raise KeyTypeError("refusing to use PEM-encoded key material as an HMAC secret "
"(RS256->HS256 confusion defense)")
else: # RS*/PS*/ES*/EdDSA
# raw bytes/str are NEVER accepted as a public key
if isinstance(key, (str, bytes)):
raise KeyTypeError("...raw bytes/str are never accepted as the verification key")
if hasattr(key, "private_bytes"): # private key refused
raise KeyTypeError("...supply the public key")
_check_public_key_family(key, alg) # RSA vs EC vs Ed25519 + curve check
This blocks the attack three independent ways: the forged token says HS256 while the server pins RS256 → AlgorithmMismatchError; if a server were misconfigured to pin HS256, an RSA key object → KeyTypeError, and the PEM bytes as a "secret" → KeyTypeError (PEM-as-HMAC defense). Signatures are genuinely verified (cryptography: RSA PKCS1v15/PSS, ECDSA raw r‖s, Ed25519, constant-time HMAC).
Embedded-JWK confusion: any jwk / jku / x5u / x5c header → EmbeddedKeyError. The verification key is always the caller's, never the token's.
Duplicate alg headers: JSON is parsed with a strict object_pairs_hook that rejects any repeated key, so {"alg":"none","alg":"RS256"} (which Python's default parser collapses to last-wins RS256, other parsers to first-wins) → DuplicateClaimError. Duplicate payload keys are rejected too, and the token must have exactly 3 non-empty base64url segments.
def _reject_duplicate_keys(pairs):
obj = {}
for key, value in pairs:
if key in obj:
raise DuplicateClaimError(f"duplicate JSON key {key!r} in token segment")
obj[key] = value
return obj
# ...used as: json.loads(text, object_pairs_hook=_reject_duplicate_keys)
Caller usage: HardenedJWT.verify(token, public_key, "RS256") or HardenedJWT.verify_pem(token, public_pem, "RS256") for asymmetric, HardenedJWT.verify(token, secret, "HS256") for symmetric. Never derive the algorithm from the token.
`python3 test_hardened_jwt.py` → **23/23 PASS** (also `pytest`: 23 passed). Core exploit test (`test_forged_hs256_signed_with_public_key_bytes_fails`) proves a token signed with the public-key bytes fails under every presentation (RS256-pinned → `AlgorithmMismatchError`; HS256-pinned with key object → `KeyTypeError`; HS256-pinned with PEM bytes → `KeyTypeError`).
Live demonstration:
```
Attacker builds HS256 token with signature = HMAC(RSA public key bytes)
vulnerable_verify(forged, pubkey) -> {'sub': 'admin', 'role': 'admin', 'iss': 'attacker'} # BUG
Hardened verifier (pinned RS256, public key object):
REJECTED: verify(forged, RSA pubkey obj, 'RS256')
-> AlgorithmMismatchError: header alg 'HS256' does not match the pinned algorithm 'RS256'
REJECTED: verify(forged, RSA pubkey obj, 'HS256')
-> KeyTypeError: ... refusing to use an asymmetric key object as an HMAC secret
REJECTED: verify(forged, PEM bytes, 'HS256')
-> KeyTypeError: refusing to use PEM-encoded key material as an HMAC secret
REJECTED: alg=none -> NoneAlgorithmError: alg=none is never accepted
REJECTED: embedded jwk -> EmbeddedKeyError: ... 'jwk'
REJECTED: duplicate alg -> DuplicateClaimError: duplicate JSON key 'alg' in token segment
Legitimate RS256 token still verifies: {'sub': 'alice', 'role': 'user'}
```
Edge cases covered: valid RS256/HS256/ES256/PS256 round-trips (incl. PEM loading); alg=none; missing/unknown/fuzzy algs (`RS257`, `rs256`, `"RS256 "`, `123`); pinned-alg mismatch both directions; key-type mismatch both directions (HS+key-object, RS+string secret, RS+raw bytes); private key refused; key-family/curve mismatch (ES256 vs RSA key); embedded `jwk`/`jku`/`x5u`/`x5c`; duplicate `alg` in all three orderings; duplicate payload key; tampered signature and tampered payload; wrong segment counts (`1/2/4` segments, `".."`, `""`); invalid base64url chars; non-object header/payload. Bonus: Python's default parser collapses `{"alg":"none","alg":"RS256"}` to last-wins `RS256` — confirming the ambiguity the duplicate-key defense kills — and PyJWT 2.10.1 with `algorithms=["RS256"]` also rejects the forgery (`InvalidAlgorithmError`), while its own PEM-as-HMAC check raises `InvalidKeyError` for the HS256 misconfiguration (same defense-in-depth as this library).{"model": "deepseek-v4-flash", "problem_class": "python-jwt-alg-confusion-rs256-hs256", "result": "passed", "tests": 23}