[Bug]: JWT Bearer npm publish PUT always 400s 'request size did not match content length' (Basic/AES fine) — core middleware, no plugins
What happened?
Every npm publish authenticated with a JWT Bearer token (i.e. with security.api.jwt configured) fails with:
400 Bad Request - PUT http://localhost:4873/@acme%2frepro-probe - request size did not match content lengthThe identical request succeeds with Basic auth, and also succeeds with AES legacy tokens (no security.api.jwt block). Only the JWT middleware path corrupts/loses the request body. This reproduces on the stock Docker image with default local storage — no plugins — so it is core middleware, not a storage backend.
Minimal repro (stock image, ~2 minutes):
config.yaml:
listen: 0.0.0.0:4873
storage: /verdaccio/storage/data
security:
api:
jwt:
sign:
expiresIn: 30d
web:
sign:
expiresIn: 7d
auth:
htpasswd:
file: /verdaccio/storage/htpasswd
max_users: 1000
packages:
'@acme/*':
access: $authenticated
publish: acme-ci
unpublish: acme-ci
'**':
access: $all
log: { type: stdout, format: pretty, level: info }docker run -d --name v -p 4873:4873 \
-v "$PWD/config.yaml":/verdaccio/conf/config.yaml:ro verdaccio/verdaccio:6.9.0
# create user + mint a JWT via the npm token API
curl -s -XPUT http://localhost:4873/-/user/org.couchdb.user:acme-ci \
-H 'Content-Type: application/json' -d '{"name":"acme-ci","password":"repro-pass-123"}'
TOKEN=$(curl -s -XPOST http://localhost:4873/-/npm/v1/tokens \
-u acme-ci:repro-pass-123 -H 'Content-Type: application/json' \
-d '{"password":"repro-pass-123","readonly":false,"cidr_whitelist":[]}' | jq -r .token)
# any minimal package:
mkdir p && cd p && npm init -y >/dev/null && npm pkg set name=@acme/repro-probe
# FAILS (Bearer JWT):
npm publish --registry http://localhost:4873/ \
--//localhost:4873/:_authToken="$TOKEN"
# => 400 request size did not match content length
# SUCCEEDS (Basic, same package/route):
npm publish --registry http://localhost:4873/ \
--//localhost:4873/:_auth="$(printf 'acme-ci:repro-pass-123' | base64)"A raw fetch PUT with an explicit correct Content-Length shows the same split (Bearer → 400, Basic → normal 409/201), so it is not an npm-client artifact.
Where it seems to live: in @verdaccio/auth's apiJWTmiddleware, the request is req.pause()d and next() resumes it. The JWT verification branch (handleJWTAPIMiddleware → "handle jwt token") calls next() synchronously after getMiddlewareCredentials, while the Basic/AES branches go through the async authenticate() callback. Experimentally deferring the JWT branch's next() with setImmediate changes the failure from the 400 to 500 stream is not readable — consistent with the paused stream's buffered data being lost/consumed differently on the synchronous path before body-parser/raw-body reads it.
Impact: any registry that enables security.api.jwt (required for npm-token-API-minted tokens to authenticate the npm API) cannot publish with those tokens at all; operators are forced back to long-lived Basic credentials for publish lanes.
Reproduced identically on 6.7.4, 6.8.0, and 6.9.0 (digest-pinned stock images), Docker on Linux, npm 10/11, no reverse proxy.
Version
6.x (Stable)
Version details
6.9.0 (also reproduced on 6.7.4 and 6.8.0 — stock verdaccio/verdaccio Docker images)
Output server log info
info <-- 172.17.0.1 requested 'PUT /@acme%2frepro-probe'
http <-- 400, user: acme-ci(172.17.0.1), req: 'PUT /@acme%2frepro-probe', error: request size did not match content lengthNode.js Version
In-image Node (verdaccio/verdaccio:6.9.0); client Node 22/24, npm 10/11
Package manager
npm
Operating system
Linux (Docker)
Using reverse proxy
No reverse proxy — direct to the container port.
Source: verdaccio/verdaccio