CodeVetter · Repo Unpack · Behavior traces
auth0/node-jsonwebtoken
Three flows matter: module load (feature detection), signing, and verification. Module load is unusual in that it bakes Node-version capability into module-scope constants, so the same code path behaves differently on Node 14 vs Node 16 without any runtime branch. Signing is a long linear gauntlet of eleven guards before any crypto happens. Verification is the inverse-ordered gauntlet, and its ordering is load-bearing: algorithm allow-listing and key-type checks must both precede `jws.verify`. Both entry functions branch at the very end into callback vs synchronous return.
- MODULE LOAD: `require('jsonwebtoken')` → index.js requires decode/verify/sign → sign.js requires `lib/psSupported` → `semver.satisfies(process.version, '^6.12.0 || >=8.0.0')` → if true, `SUPPORTED_ALGS.splice(3, 0, 'PS256','PS384','PS512')` executes at module scope. Algorithm support is therefore frozen at first require.
- SIGN step 1-2: normalize arity (`typeof options === 'function'` → shift to callback), compute `isObjectPayload` as `typeof payload === 'object' && !Buffer.isBuffer(payload)`, then build `header` via `Object.assign`.
- SIGN step 3-4: reject missing key unless `algorithm === 'none'`, then coerce to `KeyObject` via `createPrivateKey` → `createSecretKey` fallback.
- SIGN step 5: enforce symmetry — `header.alg.startsWith('HS')` requires `key.type === 'secret'`; `/^(?:RS|PS|ES)/` requires `key.type === 'private'`; and for non-ES RSA algs, `asymmetricKeyDetails.modulusLength < 2048` is rejected (guarded by an `!== undefined` check for pre-Node-15 where the field is absent).
- SIGN step 6-8: `validatePayload` (allowUnknown=true, checks only iat/exp/nbf are numbers) → clone payload unless `mutatePayload` → reject `exp`+`expiresIn` and `nbf`+`notBefore` double-specification → `validateOptions` (allowUnknown=false).
- SIGN step 9-11: `validateAsymmetricKey(header.alg, key)` unless opted out → compute `timestamp` and apply `noTimestamp`/`iat` → `timespan` for `nbf` and `exp` → copy the four `options_to_payload` claims.
- SIGN terminal branch (async): wrap callback in `once()`, then `jws.createSign({header, privateKey, payload, encoding})` with `.once('error', callback)` and `.once('done', ...)`; the done handler re-checks `signature.length < 256` as a pre-Node-15 proxy for the 2048-bit modulus rule.
- SIGN terminal branch (sync): `jws.sign({header, payload, secret, encoding})` returns the token, followed by the identical `signature.length < 256` check which `throw`s rather than calling back. Both branches carry a `TODO: Remove ... once node 15+ is the minimum supported version`.
- VERIFY step 1-4: arity normalization → clone options → build `done` (callback or throw-on-error closure) → validate `clockTimestamp`/`nonce`/`allowInvalidAsymmetricKeyTypes` shapes → compute `clockTimestamp` → reject falsy or non-string `jwtString` → split into exactly 3 parts.
- VERIFY step 5-6: `decode(jwtString, {complete: true})` inside a try/catch (returns `invalid token` if falsy), then choose `getSecret` — either the caller's key-lookup function or a trivial wrapper that immediately yields the static key. All remaining logic runs inside that callback.
- VERIFY step 7: the three-way signature/key presence matrix — signature-without-key → `secret or public key must be provided`; key-without-signature → `jwt signature is required`; neither-plus-no-algorithms → `please specify "none" in "algorithms"`.
- VERIFY step 8-10 (the security core, in this exact order): coerce key → derive default `algorithms` from key type → reject `header.alg` not in the list (`invalid algorithm`) → reject HS-alg-with-non-secret-key and RS/PS/ES-alg-with-non-public-key → `validateAsymmetricKey` → only then `jws.verify`.
- VERIFY step 11 (claims, in order): `nbf` (type check then `> clockTimestamp + clockTolerance` → `NotBeforeError`) → `exp` (→ `TokenExpiredError`) → `audience` → `issuer` → `subject` → `jwtid` → `nonce` → `maxAge` (→ `TokenExpiredError('maxAge exceeded')`) → return payload or the `complete` triple.
- KEY-CONFUSION TRACE: an attacker-forged `{alg: HS256, kid: rsaKeyId}` token presented with the legitimate RSA public key reaches line 148, where `header.alg.startsWith('HS') && key.type !== 'secret'` throws `secretOrPublicKey must be a symmetric key when using HS256` — before `jws.verify` is ever called. The test asserts this for both KeyObject and PEM forms.