Vulnerability GHSA-w7vc-732c-9m39
Summary
PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS
Details
[!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.
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 ifFalse, it replacespayload = detached_payloadand rebuilds the signing input
This behavior is visible in decode_complete():
_load(jwt)happens before theb64=falsehandling- then
payload = detached_payloadandsigning_input = ... detached_payloadhappens 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
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”.)
Proof of Concept - file names + results
PoC placement
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
=== 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
=== 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
=== 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.
Fix
Goal
Prevent unbounded resource consumption from an attacker-controlled payload segment that is unused in b64=false detached flow.
Minimal change strategy
In _load() (or by refactoring parse order), do not Base64-decode payload_segment until after you know whether b64=false applies.
Two safe options:
-
Reject non-empty payload segment when
b64=false- Parse header first
- If
b64is false andpayload_segmentis non-empty → raiseDecodeErrorbefore decoding - Then verification uses
detached_payloadonly
-
Skip decoding payload segment entirely when
b64=false- Keep payload segment as raw bytes or empty
- Use detached payload for signing input
This aligns with the idea that detached payload is the trusted payload input for verification; the compact payload segment should not become a resource amplification vector.
(Implementation context: the current decode order and unconditional base64url_decode(payload_segment) are visible in the file and line region around _load() and decode_complete() ([GitHub][1]).)
Related Vulnerabilities
Other vulnerabilities affecting the same packages