GitHub Advisoryadvisory · PyPI
GHSA-w7vc-732c-9m39 PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS
MEDIUM
Official page ↗
- Aliases
- CVE-2026-48525
- Ecosystem
- PyPI
- Packages
- pyjwt
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
- Weaknesses
- CWE-400
- Reviewed
- 2026-06-15
Affected packages
• PyPI pyjwt: >= 2.8.0, < 2.13.0 → fixed in 2.13.0
Description
> [!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
• client_localhost.py
• flood_localhost.py
---
PoC # 1 - Localhost verification server
File: 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
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: [flood_localhost.py](https://github.com/user-attachments/files/26132760/flood_loc…
Related
Alias · CVE (1)
- CVE-2026-48525 PyJWT is a JSON Web Token implementation in Python. vulnerability
Weakness · CWE (1)
- CWE-400 Uncontrolled Resource Consumption weakness