GHSA-w7vc-732c-9m39: 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 codeIn `jwt/api_jws.py`, `decode_complete()` does the following (order matters):Calls `_load(jwt)` first, which decodes the token segmentsOnly after that, checks `header.get("b64")` and if `False`, it replaces `payload = detached_payload` and rebuilds the signing inputThis behavior is visible in `decode_complete()`:`_load(jwt)` happens **before** the `b64=false` handlingthen `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 vulnerabilityFor `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 lengthImpact (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 ModelTypical real deploymentA service verifies signed HTTP requests or webhooks using detached JWS:token is provided in JSON body / query / headeractual payload is the HTTP request body passed as `detached_payload`Attackerremote unauthenticated clientcan send requests to verify endpointdoes **not** need a valid signature (invalid signature still triggers the expensive decode path)Attack chainAttacker crafts a JWS compact token with header containing `"b64": false` and `crit:["b64"]`.Attacker inflates the **payload segment** (middle segment) to millions of Base64URL characters.Server calls `PyJWS.decode(...detached_payload=...)`.PyJWT decodes the inflated segment (CPU + memory).Signature is rejected afterward (401) — but resources already consumed.Repeated requests or bursts cause queueing/worker starvation → DoS.Proof of Concept - file names + resultsPoC 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)**Key takeaways from these results**At **8,000,000 chars**, a single invalid-signature request still causes:**~23 ms** server work**~32 MB** peak allocationsreturns **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,000payload-chars = 2,000,000payload-chars = 8,000,000**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_localhost.py)
**Purpose:** sends **N concurrent** invalid-signature requests over HTTP to demonstrate queueing/worker starvation.Results (your run: 20 concurrent @ 8,000,000 chars)**Interpretation**Each request still costs ~**20–23 ms** server processing and **~32 MB** peak allocations.But client-observed latency rises up to **~1.15 seconds** because requests queue behind each other → clear worker starvation/HoL blocking.All were rejected with **401 InvalidSignatureError** → still unauthenticated.FixGoalPrevent unbounded resource consumption from an attacker-controlled payload segment that is unused in `b64=false` detached flow.Minimal change strategyIn `_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 firstIf `b64` is false and `payload_segment` is non-empty → raise `DecodeError` *before* decodingThen verification uses `detached_payload` only**Skip decoding payload segment entirely when `b64=false`**Keep payload segment as raw bytes or emptyUse detached payload for signing inputThis 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]).)WorkaroundsEnforce strict **max token length** at the HTTP boundary (proxy/gateway).Apply rate limiting on verification endpoints.If detached JWS (`b64=false`) is not needed in your app, reject tokens where header includes `"b64": false`.
CVSS v3.1: 5.3