You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS
Moderate severity
GitHub Reviewed
Published
May 21, 2026
in
jpadilla/pyjwt
•
Updated Jun 15, 2026
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 segmentbefore 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).
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
Attacker 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.
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.
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 b64 is false and payload_segment is non-empty → raise DecodeErrorbefore decoding
Then verification uses detached_payload only
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]).)
Workarounds
Enforce 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.
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-provideddetached_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.pyPyJWS.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):_load(jwt)first, which decodes the token segmentsheader.get("b64")and ifFalse, it replacespayload = detached_payloadand rebuilds the signing inputThis behavior is visible in
decode_complete():_load(jwt)happens before theb64=falsehandlingpayload = 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=falsedetached JWS, the payload segment in compact form is effectively not needed for verification in PyJWT’s own logic (since the library usesdetached_payloadas the real payload). Yet PyJWT still decodes it first, meaning:Impact (evidence-driven)
Security impact
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
critvalidation, but the issue here is decode order / unbounded decode of an unused segment.)Affected Versions
(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:
detached_payloadAttacker
Attack chain
"b64": falseandcrit:["b64"].PyJWS.decode(...detached_payload=...).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)
Key takeaways from these results
At 8,000,000 chars, a single invalid-signature request still causes:
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
payload-chars = 2,000,000
payload-chars = 8,000,000
Why this is strong evidence
PoC # 3 - Localhost flood / burst concurrency
File: 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
Fix
Goal
Prevent unbounded resource consumption from an attacker-controlled payload segment that is unused in
b64=falsedetached flow.Minimal change strategy
In
_load()(or by refactoring parse order), do not Base64-decodepayload_segmentuntil after you know whetherb64=falseapplies.Two safe options:
Reject non-empty payload segment when
b64=falseb64is false andpayload_segmentis non-empty → raiseDecodeErrorbefore decodingdetached_payloadonlySkip decoding payload segment entirely when
b64=falseThis 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()anddecode_complete()([GitHub][1]).)Workarounds
b64=false) is not needed in your app, reject tokens where header includes"b64": false.References