| Audited | auth0/node-jsonwebtoken at commit b924272f29192e12926b5414546f7c5bfcc9579d |
|---|---|
| Date | 11 October 2026 |
| How it ran | API run on the Nacodex server, full audit, Standard review |
| Verdict after review | Pass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 2 findings were first rated Medium or High; 0 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Code locations link to the file and line at the audited commit.
Low after review (3)
From the report
const SUPPORTED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'HS256', 'HS384', 'HS512', 'none'];
The algorithm families and the algorithm-to-key-type mapping are declared independently in sign.js, verify.js and lib/validateAsymmetricKey.js. Only the first two gate the PS algorithms on platform support. verify.js even declares two identical RSA lists (PUB_KEY_ALGS and RSA_KEY_ALGS). The lists agree today, but adding or removing an algorithm means editing three places, and missing one leaves sign and verify disagreeing about what is allowed.
Move the algorithm families and the key-type mapping into one shared module under lib/, apply the PS-support gate there once, and import that module from all three files.
Review: sign.js:14 const SUPPORTED_ALGS = ['RS256', ... 'none']; (PS spliced in at :15-17). verify.js:11 const PUB_KEY_ALGS = ['RS256', 'RS384', 'RS512']; and :13 const RSA_KEY_ALGS = ['RS256', 'RS384', 'RS512']; (identical, both get PS added at :17-18). lib/validateAsymmetricKey.js:4-8 allowedAlgorithmsForKeys = { ec:[...], rsa:[RS..., PS...], 'rsa-pss':[PS...] }, PS listed unconditionally (no PS_SUPPORTED gate). All claims check out. Pure maintainability; no behavioural bug today.
verify.js:148From the report
if (header.alg.startsWith('HS') && secretOrPublicKey.type !== 'secret') {
Take a token with an empty signature part, an alg of HS256 and a caller who passes algorithms: ['HS256']. If the key resolves to null or undefined, every earlier guard passes: lines 108, 112, 116, 120 and 144. Execution then reads .type on null and throws a TypeError. A common setup triggers this: an async key resolver that returns no key for an unknown kid. The attacker controls both the kid and the empty signature. The throw happens inside the key-resolver callback instead of reaching the caller's done callback, so it can surface as an uncaught exception or unhandled rejection and take the process down. Line 150 has the same problem for RS, PS and ES algorithms.
Once the key is resolved, and before the algorithm and key-type checks, return done(new JsonWebTokenError('secret or public key must be provided')) whenever the key is null or undefined and the header algorithm is not none. Add tests for unsigned tokens with a missing key under explicit HS and RS algorithm lists.
Review: Code (verify.js:148, 150): Trace, token <hdr alg=HS256>.<payload>. (empty signature), key null/undefined, algorithms:['HS256']: :106 hasSignature=false; :108 needs key (false); :112 needs signature (false); :116 !options.algorithms false; :120 key null -> skipped; :132 algorithms set; :144 HS256 in list; :148 null.type -> TypeError. The mechanism is real (decode accepts an empty signature: jws JWS_REGEX ([a-zA-Z0-9\-_]+)?$ makes it optional, verify-stream.js:8). Why it is not MEDIUM: (a) needs the caller's key lookup to return a falsy key WITHOUT an error. jwks-style resolvers return an error for an unknown kid, which takes the :102-104 branch. (b) The caller must list an HS/RS/ES alg in algorithms. (c) In the sync path (static key or undefined env secret) the TypeError is thrown out of verify(), the same channel where verify already throws JsonWebTokenError for every invalid token, so ordinary try/catch or Express error middleware yields a 500, not a crash. Process death needs an async resolver whose callback fires outside any caller handler (uncaught exception) or a promise wrapper without catch. That is a misuse-dependent robustness bug. The report's "attacker can crash a service" is the worst case, not the typical case. Fix proposed in the report is sound (null-key check before :144). No match for this exact defect found.
Upstream: No exact upstream report; related items: #926.
sign.js:221From the report
if (typeof payload[claim] !== 'undefined') {
return failure(new Error('Bad "options.' + key + '" option. The payload already has an "' + claim + '" property.'));
}
This check runs inside Object.keys(...).forEach(...), so the return only exits that iteration. In callback mode, failure() calls the callback with the error and then execution carries on into signing. The callback is wrapped in once() only after this point, so it is called again with a valid signed token. A caller who passes, say, audience while the payload already has aud sees an error and then a success, and the token keeps the payload's original claim. Depending on the caller, that means double responses, a "headers already sent" error, or a token issued after an error was reported. Sync mode is not affected, because failure() throws there.
Replace the forEach with a plain for...of loop so return failure(...) exits sign. Alternatively, wrap the callback in once() before the first possible failure() call. Add an async test asserting the callback runs exactly once on this conflict.
Review: sign.js:217-225: return exits only the forEach callback. In callback mode failure calls callback(err) (:103-105), execution continues, :230 wraps callback in a fresh once(...), :232-243 signs and calls it again with a token. The wrapper is created after the first call, so the first call is not guarded. Claim fully correct; sync mode throws (:107) so it is unaffected. Trigger is a developer programming error (same claim in payload and options), not attacker input; the token issued keeps the payload's original claim. Reviewed severity LOW (real contract violation, contained blast radius). Known upstream: YES. Issue #1000 "jwt.sign() callback is executed twice for 'The payload already has an ... property' errors" (open, created 2025-09-10, same reproduction with issuer); open PRs #1002, #1011, #1016, #1017 all fixing it. Not fixed at this commit.
Upstream: Already reported upstream: #1000, #1002, #1011, #1016, #1017.
Info after review (3)
verify.js:67From the report
const parts = jwtString.split('.');
...
decodedToken = decode(jwtString, { complete: true });
verify splits, base64-decodes and JSON-parses the attacker-supplied token before the signature is checked, and jws.verify then decodes it a second time. The work grows only linearly with input size, so this is not an amplification bug. Still, the library has no upper bound of its own: we searched the production code for a length check and found none. Protection against oversized tokens depends entirely on every caller's HTTP layer.
Add an optional maxTokenLength setting with a conservative default of a few kilobytes. Reject longer tokens with a JsonWebTokenError before any splitting or parsing.
Review: verify.js:67-76: No length check exists in verify.js, decode.js, index.js (grep for length/size: only parts.length !== 3). Absence is factual. Report claim "jws.verify then decodes it a second time" is wrong. jws@4.0.1 jwsVerify (lib/verify-stream.js:44-55) only does split('.') for signature and secured input, then algo.verify. No base64 decode of the payload and no JSON.parse; the JSON parse happens once, in jwsDecode (:57-78) via decode.js. Work is linear, one parse. Node's HTTP stack already caps header size (default 16 KB), the usual carrier of a JWT. A library-level cap is a hardening wish, not a defect. Reviewed severity INFO.
verify.js:122 (line corrected on review; the report cites :120) (same pattern at sign.js:114-124)From the report
secretOrPublicKey = createPublicKey(secretOrPublicKey);
...
secretOrPublicKey = createSecretKey(typeof secretOrPublicKey === 'string' ? Buffer.from(secretOrPublicKey) : secretOrPublicKey);
Both entry points carry the same try-asymmetric-then-fall-back-to-secret cascade. With only two copies this is acceptable, but a fix to one copy could easily miss the other.
If a third copy appears, extract a small lib/toKeyObject helper that takes the asymmetric factory as an argument.
Review: verify.js:120-130 (the createPublicKey call is line 122, createSecretKey line 125; the report cites :120 which is the enclosing if). sign.js:114-124 same cascade with createPrivateKey/createSecretKey. Correct as described.
>=.github/workflows/release.yml:41Review: run: pip install boto3>=1.34.159 requests>=2.32.3 rl-deploy>=2.2.3.0 pip-system-certs>=4.0 under shell: bash: >=1.34.159 is parsed as a redirect to a file named =1.34.159, so the version floors are dropped (latest installed, stray files created). Real, CI-only, no library impact. Report's statement is accurate.