verify() resolves a string secret by attempting createPublicKey() first, costing 4x-52x on the HS* path
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 65/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- javascript, node.js
- Lĩnh vực
- backend, cryptography, performance, security
Hướng nghiên cứu
Bắt đầu trong verify.js quanh các dòng 120–127 và truy vết cách header.alg cùng các kiểu khóa được kiểm tra quanh dòng 148. Xem lại các test mocha/chai hiện có về việc nhầm lẫn kiểu khóa, sau đó xác minh rằng đường dẫn string-secret đã được tối ưu vẫn giữ nguyên các kiểm tra đó và tránh việc parse public-key bị lỗi khi xác minh HS*. Hoàn tất khi các test liên quan đều đạt và đường dẫn xác minh được báo cáo không còn thực hiện lần thử parse đó.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Summary
When secretOrPublicKey is not already a KeyObject, verify() resolves it by
calling createPublicKey() first and falling back to createSecretKey() in the
catch. For an HMAC string secret the first call cannot succeed, so every
verification constructs and discards an OpenSSL parse failure.
The conversion it falls back to costs 0.69 µs. The attempt that precedes it
costs 17.6 µs on Node 26 and 400 µs on Node 18, where it is 97% of the
entire verify() call.
Passing a pre-parsed KeyObject avoids this completely, so there is a clean
workaround. This report is about the cost on the documented string-secret path.
Reproduction
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const SECRET = 'a-shared-secret-of-reasonable-length';
const KEYOBJ = crypto.createSecretKey(Buffer.from(SECRET));
const token = jwt.sign({ sub: 'u' }, SECRET, { algorithm: 'HS256', expiresIn: '1h' });
const N = 20000;
function bench(label, fn) {
for (let i = 0; i < N; i++) fn(); // warm
const t0 = process.hrtime.bigint();
for (let i = 0; i < N; i++) fn();
console.log(label.padEnd(34),
(Number(process.hrtime.bigint() - t0) / 1000 / N).toFixed(2).padStart(8), 'us/call');
}
bench('jwt.verify, string secret', () => jwt.verify(token, SECRET, { algorithms: ['HS256'] }));
bench('jwt.verify, KeyObject secret', () => jwt.verify(token, KEYOBJ, { algorithms: ['HS256'] }));
bench('createPublicKey(secret) [throws]', () => { try { crypto.createPublicKey(SECRET); } catch (_) {} });
bench('createSecretKey(Buffer.from(s))', () => crypto.createSecretKey(Buffer.from(SECRET)));
node v26.6.0, openssl 3.6.4, jsonwebtoken 9.0.3 — macOS arm64:
jwt.verify, string secret 29.51 us/call
jwt.verify, KeyObject secret 7.26 us/call
createPublicKey(secret) [throws] 17.60 us/call
createSecretKey(Buffer.from(s)) 0.69 us/call
node v18.20.8, openssl 3.0.16, jsonwebtoken 9.0.3 — node:18.20.8-alpine, same machine:
jwt.verify, string secret 412.18 us/call
jwt.verify, KeyObject secret 7.86 us/call
createPublicKey(secret) [throws] 400.85 us/call
createSecretKey(Buffer.from(s)) 1.73 us/call
Root cause
if (secretOrPublicKey != null && !(secretOrPublicKey instanceof KeyObject)) {
try {
secretOrPublicKey = createPublicKey(secretOrPublicKey); // cannot succeed for an HMAC secret
} catch (_) {
try {
secretOrPublicKey = createSecretKey(/* ... */);
Confirmed independently with node --cpu-prof over 200k iterations, which
attributes self-time per frame with no differencing involved:
| runtime | createPublicKey self-time |
|---|---|
node:18.20.8-alpine |
94.7% |
node:26.6.0-alpine |
57.9% |
Why the magnitude varies so widely
The cost tracks the bundled OpenSSL rather than Node or V8. Four container
runtimes, 10 process invocations each, medians with bootstrap 95% CIs:
| runtime | OpenSSL | V8 | failed attempt |
|---|---|---|---|
node:18.20.8-alpine |
3.0.16 | 10.2 | 373.79 µs [371.5, 375.8] |
node:20-alpine |
3.0.19 | 11.3 | 420.62 µs [419.5, 422.6] |
node:26.6.0-alpine |
3.5.7 | 14 | 22.94 µs [22.8, 23.3] |
node:26.6.0-bookworm |
3.5.7 | 14 | 16.58 µs [16.4, 17.9] |
Node 18 and Node 20 are different V8 majors sharing the OpenSSL 3.0 series and
both pay ~400 µs; OpenSSL 3.5 pays ~20 µs. createPublicKey() on a valid PEM
moves the same way (115.8 µs → 12.1 µs), so this looks like general OpenSSL 3.0
key-parsing cost rather than anything specific to the failure path.
Everything else in verify() is stable across all four: KeyObject verify spans
1.26x, HMAC 1.47x, decode 1.70x. Only this attempt spans 25x.
node:18-alpine remains a very common base image, and the overhead there is
roughly 50x the cost of the verification itself.
Proposed patch
Resolve a plain string secret directly when the token's alg is HS*:
if (secretOrPublicKey != null && !(secretOrPublicKey instanceof KeyObject)) {
- try {
- secretOrPublicKey = createPublicKey(secretOrPublicKey);
- } catch (_) {
- try {
- secretOrPublicKey = createSecretKey(typeof secretOrPublicKey === 'string' ? Buffer.from(secretOrPublicKey) : secretOrPublicKey);
+ var isPlainStringSecret = typeof secretOrPublicKey === 'string'
+ && secretOrPublicKey.indexOf('-----BEGIN') === -1
+ && secretOrPublicKey.trim().charAt(0) !== '{';
+
+ if (typeof header.alg === 'string' && header.alg.startsWith('HS') && isPlainStringSecret) {
+ try {
+ secretOrPublicKey = createSecretKey(Buffer.from(secretOrPublicKey));
} catch (_) {
return done(new JsonWebTokenError('secretOrPublicKey is not valid key material'))
}
+ } else {
+ try {
+ secretOrPublicKey = createPublicKey(secretOrPublicKey);
+ } catch (_) {
+ try {
+ secretOrPublicKey = createSecretKey(typeof secretOrPublicKey === 'string' ? Buffer.from(secretOrPublicKey) : secretOrPublicKey);
+ } catch (_) {
+ return done(new JsonWebTokenError('secretOrPublicKey is not valid key material'))
+ }
+ }
}
}
With this applied, verify() goes 29.51 µs → 7.21 µs on Node 26 — within
1.2 µs of passing a KeyObject directly — with no change required in calling code.
A constraint on any fix here
The attempt is not purely wasteful, and I think this is the important part of the
report.
createPublicKey() succeeding on asymmetric key material is what produces a
KeyObject whose type the algorithm/key-type check at
L148
depends on. Any change that dispatches on header.alg while relaxing what the
key material may be would alter which inputs reach that check, and the check is
what keeps symmetric and asymmetric material from being interchanged.
That is why the guard above is narrow rather than simply alg.startsWith('HS'):
the fast path is restricted to string secrets that cannot be PEM or JWK, so
anything that might be asymmetric still resolves exactly as it does today.
I verified that this narrowing preserves the check, and that a variant without
the material restriction does not. I would rather not spell out the second half
in a public issue — happy to share the detail privately if it helps review.
Tests
Four assertions, in the project's mocha/chai style, covering both the invariant
and the absence of regression. They pass against master and against the patch,
and the first fails against a version of the patch lacking the material
restriction — so it constrains the change rather than merely describing it.
I expect the existing suite already covers key-type confusion; if so, the useful
contribution may be to confirm those tests still pass rather than to add new
ones. Happy either way.
Environment
jsonwebtoken9.0.3 (current latest, published 2026-07-08)- Node v18.20.8 / v20.20.2 / v26.6.0
- macOS 26.5 arm64 host; containers via Docker Desktop, arm64 only
- x86_64 not yet tested
What I am not claiming
- Not a vulnerability. Current behaviour is correct. This is a performance
report, with a note about what a fix must preserve. - One machine, one architecture. The cross-runtime ordering is consistent and
the effect sizes are large, but absolute figures come from a single host. sign()has similar key-resolution logic; I have not benchmarked it.- Glad to open a PR with the patch and tests if that would be useful.
- Ngôn ngữ chính
- JavaScript
- Star
- 18.2k
- Fork
- 1.3k
- Chỉ số merge pull request
- Không có pull request nào được merge trong 30 ngày
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của auth0/node-jsonwebtoken
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
auth0/node-jsonwebtoken#1042 · 1 bình luận ·
-
`jwt.sign()` callback is executed twice for "The payload already has an "..." property" errorsCó thể đã có người làm @cobyfrombrooklyn-bot đã nhận 227 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
auth0/node-jsonwebtoken#1000 · 2 bình luận · 1 reaction ·
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 45/100
auth0/node-jsonwebtoken#1048 ·
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 10/100
auth0/node-jsonwebtoken#1034 ·
-
jwt.verify accepts tokens with unrecognized `crit` header extensions (RFC 7515 §4.1.11)Có thể đã có người làm @ArockiaRajamanickam đã nhận 69 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 48/100
auth0/node-jsonwebtoken#1032 ·
Tất cả issue của auth0/node-jsonwebtoken
Issue tương tự
-
bug user-priority/P2
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
t8y2/dbx#11718 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug confirmed perf
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
videojs/video.js#9400 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
agent/scanner bug hive/hosted-available-lke648397-260827-5n31
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
rescript-lang/rescript#8765 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[workshop-sim] Improve: Add a required self-check gate to the agentic-workflows concept pageĐang mởfeedback simulation workshop
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
githubnext/gh-aw-workshop#4417 ·
Maintainer thường phản hồi trong vòng 1 ngày