From b129b39555487967ee3ce647eb2d1e7ea577d74f Mon Sep 17 00:00:00 2001 From: MarkXian Date: Fri, 17 Jul 2026 10:17:51 +0800 Subject: [PATCH 1/4] fix: honor per-request key override on jwtVerify and jwtSign A `key` passed in the per-request options of `request.jwtVerify` (and `reply.jwtSign`) was silently discarded: after the secret resolver ran, `mergeOptionsWithKey` overwrote the caller-provided `key` with the resolved registration secret, so the documented "key overrides secret" behaviour did not apply on these paths. Capture the caller-provided `key` before the options are merged and, when present, use it instead of the resolved secret. Registration-level secret resolution and global option precedence are unchanged. Closes #346 --- index.js | 20 ++++++++++++++++++-- test/jwt.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 486c3bb..197d164 100644 --- a/index.js +++ b/index.js @@ -383,6 +383,10 @@ function fastifyJwt (fastify, options, next) { }) } + // Capture any per-request `key` before merging, so it can take precedence + // over the resolved secret (as documented for the `sign.key` option). + const requestSignKey = options.sign ? options.sign.key : options.key + if (options.sign) { const localSignOptions = convertTemporalProps(options.sign) // New supported contract, options supports sign and can expand @@ -409,7 +413,11 @@ function fastifyJwt (fastify, options, next) { }, function sign (secretOrPrivateKey, callback) { if (useLocalSigner) { - const signerOptions = mergeOptionsWithKey(options.sign || options, secretOrPrivateKey) + const localSignOptions = options.sign || options + // A per-request `key` overrides the resolved secret; otherwise the resolved secret is used. + const signerOptions = requestSignKey + ? localSignOptions + : mergeOptionsWithKey(localSignOptions, secretOrPrivateKey) const localSigner = createSigner(signerOptions) const token = localSigner(payload) callback(null, token) @@ -477,6 +485,10 @@ function fastifyJwt (fastify, options, next) { options = {} } + // Capture any per-request `key` before merging, so it can take precedence + // over the resolved secret (as documented for the `verify.key` option). + const requestVerifyKey = options.verify ? options.verify.key : options.key + if (options.decode || options.verify) { const localVerifyOptions = convertTemporalProps(options.verify, true) // New supported contract, options supports both decode and verify @@ -508,7 +520,11 @@ function fastifyJwt (fastify, options, next) { }, function verify (secretOrPublicKey, callback) { try { - const verifierOptions = mergeOptionsWithKey(options.verify || options, secretOrPublicKey) + const localVerifyOptions = options.verify || options + // A per-request `key` overrides the resolved secret; otherwise the resolved secret is used. + const verifierOptions = requestVerifyKey + ? localVerifyOptions + : mergeOptionsWithKey(localVerifyOptions, secretOrPublicKey) const localVerifier = getVerifier(verifierOptions, useGlobalOptions) const verifyResult = localVerifier(token) if (verifyResult && typeof verifyResult.then === 'function') { diff --git a/test/jwt.test.js b/test/jwt.test.js index fa79532..81d8112 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -3133,3 +3133,28 @@ test('local sign options should not overwrite global sign options', async functi t.assert.strictEqual(fastify.jwt.options.sign.expiresIn, '15m') }) + +test('request.jwtVerify should honor a per-request verify.key override', async function (t) { + t.plan(2) + + const fastify = Fastify() + fastify.register(jwt, { secret: 'hunter2' }) + + fastify.get('/verify', async function (request) { + return request.jwtVerify({ verify: { key: 'override' } }) + }) + + await fastify.ready() + + // Token signed with the override key, not the registration secret. + const token = createSigner({ key: 'override' })({ foo: 'bar' }) + + const response = await fastify.inject({ + method: 'get', + url: '/verify', + headers: { authorization: `Bearer ${token}` } + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(JSON.parse(response.payload).foo, 'bar') +}) From 2ddc2174db9c6eeb73a0f7a445aa9051f207be02 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Mon, 27 Jul 2026 11:35:51 +0800 Subject: [PATCH 2/4] fix: simplify key override merging --- index.js | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/index.js b/index.js index 197d164..1c2c715 100644 --- a/index.js +++ b/index.js @@ -291,7 +291,7 @@ function fastifyJwt (fastify, options, next) { function mergeOptionsWithKey (options, useProvidedPrivateKey) { if (useProvidedPrivateKey && (typeof useProvidedPrivateKey !== 'boolean')) { - return Object.assign({}, options, { key: options.key ?? useProvidedPrivateKey }) + return Object.assign({ key: useProvidedPrivateKey }, options) } else { const key = useProvidedPrivateKey ? secretOrPrivateKey : secretOrPublicKey return Object.assign(!options.key ? { key } : {}, options) @@ -383,10 +383,6 @@ function fastifyJwt (fastify, options, next) { }) } - // Capture any per-request `key` before merging, so it can take precedence - // over the resolved secret (as documented for the `sign.key` option). - const requestSignKey = options.sign ? options.sign.key : options.key - if (options.sign) { const localSignOptions = convertTemporalProps(options.sign) // New supported contract, options supports sign and can expand @@ -414,10 +410,7 @@ function fastifyJwt (fastify, options, next) { function sign (secretOrPrivateKey, callback) { if (useLocalSigner) { const localSignOptions = options.sign || options - // A per-request `key` overrides the resolved secret; otherwise the resolved secret is used. - const signerOptions = requestSignKey - ? localSignOptions - : mergeOptionsWithKey(localSignOptions, secretOrPrivateKey) + const signerOptions = mergeOptionsWithKey(localSignOptions, secretOrPrivateKey) const localSigner = createSigner(signerOptions) const token = localSigner(payload) callback(null, token) @@ -485,10 +478,6 @@ function fastifyJwt (fastify, options, next) { options = {} } - // Capture any per-request `key` before merging, so it can take precedence - // over the resolved secret (as documented for the `verify.key` option). - const requestVerifyKey = options.verify ? options.verify.key : options.key - if (options.decode || options.verify) { const localVerifyOptions = convertTemporalProps(options.verify, true) // New supported contract, options supports both decode and verify @@ -521,10 +510,7 @@ function fastifyJwt (fastify, options, next) { function verify (secretOrPublicKey, callback) { try { const localVerifyOptions = options.verify || options - // A per-request `key` overrides the resolved secret; otherwise the resolved secret is used. - const verifierOptions = requestVerifyKey - ? localVerifyOptions - : mergeOptionsWithKey(localVerifyOptions, secretOrPublicKey) + const verifierOptions = mergeOptionsWithKey(localVerifyOptions, secretOrPublicKey) const localVerifier = getVerifier(verifierOptions, useGlobalOptions) const verifyResult = localVerifier(token) if (verifyResult && typeof verifyResult.then === 'function') { From 19461f52ec638eedb8298acd29bd5111121ecdba Mon Sep 17 00:00:00 2001 From: MarkXian Date: Wed, 29 Jul 2026 17:29:13 +0800 Subject: [PATCH 3/4] fix: preserve dynamic jwt secret overrides --- index.js | 22 ++++++++++++++++++---- test/jwt.test.js | 26 +++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 1c2c715..855f305 100644 --- a/index.js +++ b/index.js @@ -121,11 +121,13 @@ function fastifyJwt (fastify, options, next) { secretOrPrivateKey = secretOrPublicKey = secret } + let hasStaticPrivateKey = false let hasStaticPublicKey = false let secretCallbackSign = secretOrPrivateKey let secretCallbackVerify = secretOrPublicKey if (typeof secretCallbackSign !== 'function') { secretCallbackSign = wrapStaticSecretInCallback(secretCallbackSign) + hasStaticPrivateKey = true } if (typeof secretCallbackVerify !== 'function') { secretCallbackVerify = wrapStaticSecretInCallback(secretCallbackVerify) @@ -289,15 +291,21 @@ function fastifyJwt (fastify, options, next) { return token } - function mergeOptionsWithKey (options, useProvidedPrivateKey) { + function mergeOptionsWithKey (options, useProvidedPrivateKey, preferOptionsKey) { if (useProvidedPrivateKey && (typeof useProvidedPrivateKey !== 'boolean')) { - return Object.assign({ key: useProvidedPrivateKey }, options) + return preferOptionsKey && options.key + ? Object.assign({ key: useProvidedPrivateKey }, options) + : Object.assign({}, options, { key: useProvidedPrivateKey }) } else { const key = useProvidedPrivateKey ? secretOrPrivateKey : secretOrPublicKey return Object.assign(!options.key ? { key } : {}, options) } } + function hasKeyOption (options) { + return !!options && Object.prototype.hasOwnProperty.call(options, 'key') + } + function checkAndMergeOptions (options, defaultOptions, usePrivateKey, callback) { if (typeof options === 'function') { return { options: mergeOptionsWithKey(defaultOptions, usePrivateKey), callback: options } @@ -373,6 +381,7 @@ function fastifyJwt (fastify, options, next) { useLocalSigner = false } + let preferLocalKey = false const reply = this if (next === undefined) { @@ -385,12 +394,14 @@ function fastifyJwt (fastify, options, next) { if (options.sign) { const localSignOptions = convertTemporalProps(options.sign) + preferLocalKey = hasKeyOption(localSignOptions) // New supported contract, options supports sign and can expand options = { sign: Object.assign({}, signOptions, localSignOptions) } } else { const localOptions = convertTemporalProps(options) + preferLocalKey = hasKeyOption(localOptions) // Original contract, options supports only sign options = Object.assign({}, signOptions, localOptions) } @@ -410,7 +421,7 @@ function fastifyJwt (fastify, options, next) { function sign (secretOrPrivateKey, callback) { if (useLocalSigner) { const localSignOptions = options.sign || options - const signerOptions = mergeOptionsWithKey(localSignOptions, secretOrPrivateKey) + const signerOptions = mergeOptionsWithKey(localSignOptions, secretOrPrivateKey, hasStaticPrivateKey && preferLocalKey) const localSigner = createSigner(signerOptions) const token = localSigner(payload) callback(null, token) @@ -468,6 +479,7 @@ function fastifyJwt (fastify, options, next) { } const useGlobalOptions = !options + let preferLocalKey = false if (typeof options === 'function') { next = options @@ -480,6 +492,7 @@ function fastifyJwt (fastify, options, next) { if (options.decode || options.verify) { const localVerifyOptions = convertTemporalProps(options.verify, true) + preferLocalKey = hasKeyOption(localVerifyOptions) // New supported contract, options supports both decode and verify options = { decode: Object.assign({}, decodeOptions, options.decode), @@ -487,6 +500,7 @@ function fastifyJwt (fastify, options, next) { } } else { const localOptions = convertTemporalProps(options, true) + preferLocalKey = hasKeyOption(localOptions) // Original contract, options supports only verify options = Object.assign({}, verifyOptions, localOptions) } @@ -510,7 +524,7 @@ function fastifyJwt (fastify, options, next) { function verify (secretOrPublicKey, callback) { try { const localVerifyOptions = options.verify || options - const verifierOptions = mergeOptionsWithKey(localVerifyOptions, secretOrPublicKey) + const verifierOptions = mergeOptionsWithKey(localVerifyOptions, secretOrPublicKey, hasStaticPublicKey && preferLocalKey) const localVerifier = getVerifier(verifierOptions, useGlobalOptions) const verifyResult = localVerifier(token) if (verifyResult && typeof verifyResult.then === 'function') { diff --git a/test/jwt.test.js b/test/jwt.test.js index 81d8112..b8229f2 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -2,7 +2,7 @@ const { test } = require('node:test') const Fastify = require('fastify') -const { createSigner } = require('fast-jwt') +const { createSigner, createVerifier } = require('fast-jwt') const jwt = require('..') const defaultExport = require('..').default const { fastifyJwt: namedExport } = require('..') @@ -3134,6 +3134,30 @@ test('local sign options should not overwrite global sign options', async functi t.assert.strictEqual(fastify.jwt.options.sign.expiresIn, '15m') }) +test('reply.jwtSign should honor a per-request sign.key override', async function (t) { + t.plan(2) + + const fastify = Fastify() + fastify.register(jwt, { secret: 'hunter2' }) + + fastify.post('/sign', async function (request, reply) { + return reply.jwtSign(request.body, { sign: { key: 'override' } }) + }) + + await fastify.ready() + + const response = await fastify.inject({ + method: 'post', + url: '/sign', + payload: { foo: 'bar' } + }) + + t.assert.strictEqual(response.statusCode, 200) + + const decoded = createVerifier({ key: 'override' })(response.payload) + t.assert.strictEqual(decoded.foo, 'bar') +}) + test('request.jwtVerify should honor a per-request verify.key override', async function (t) { t.plan(2) From ed722bbbe50725e777080dfc1fdf95b34aa017df Mon Sep 17 00:00:00 2001 From: MarkXian Date: Mon, 31 Aug 2026 10:49:36 +0800 Subject: [PATCH 4/4] fix: preserve configured keys with static secrets --- index.js | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/index.js b/index.js index 855f305..dddff40 100644 --- a/index.js +++ b/index.js @@ -302,10 +302,6 @@ function fastifyJwt (fastify, options, next) { } } - function hasKeyOption (options) { - return !!options && Object.prototype.hasOwnProperty.call(options, 'key') - } - function checkAndMergeOptions (options, defaultOptions, usePrivateKey, callback) { if (typeof options === 'function') { return { options: mergeOptionsWithKey(defaultOptions, usePrivateKey), callback: options } @@ -381,7 +377,6 @@ function fastifyJwt (fastify, options, next) { useLocalSigner = false } - let preferLocalKey = false const reply = this if (next === undefined) { @@ -394,14 +389,12 @@ function fastifyJwt (fastify, options, next) { if (options.sign) { const localSignOptions = convertTemporalProps(options.sign) - preferLocalKey = hasKeyOption(localSignOptions) // New supported contract, options supports sign and can expand options = { sign: Object.assign({}, signOptions, localSignOptions) } } else { const localOptions = convertTemporalProps(options) - preferLocalKey = hasKeyOption(localOptions) // Original contract, options supports only sign options = Object.assign({}, signOptions, localOptions) } @@ -421,7 +414,7 @@ function fastifyJwt (fastify, options, next) { function sign (secretOrPrivateKey, callback) { if (useLocalSigner) { const localSignOptions = options.sign || options - const signerOptions = mergeOptionsWithKey(localSignOptions, secretOrPrivateKey, hasStaticPrivateKey && preferLocalKey) + const signerOptions = mergeOptionsWithKey(localSignOptions, secretOrPrivateKey, hasStaticPrivateKey) const localSigner = createSigner(signerOptions) const token = localSigner(payload) callback(null, token) @@ -479,8 +472,6 @@ function fastifyJwt (fastify, options, next) { } const useGlobalOptions = !options - let preferLocalKey = false - if (typeof options === 'function') { next = options options = {} @@ -492,7 +483,6 @@ function fastifyJwt (fastify, options, next) { if (options.decode || options.verify) { const localVerifyOptions = convertTemporalProps(options.verify, true) - preferLocalKey = hasKeyOption(localVerifyOptions) // New supported contract, options supports both decode and verify options = { decode: Object.assign({}, decodeOptions, options.decode), @@ -500,7 +490,6 @@ function fastifyJwt (fastify, options, next) { } } else { const localOptions = convertTemporalProps(options, true) - preferLocalKey = hasKeyOption(localOptions) // Original contract, options supports only verify options = Object.assign({}, verifyOptions, localOptions) } @@ -524,7 +513,7 @@ function fastifyJwt (fastify, options, next) { function verify (secretOrPublicKey, callback) { try { const localVerifyOptions = options.verify || options - const verifierOptions = mergeOptionsWithKey(localVerifyOptions, secretOrPublicKey, hasStaticPublicKey && preferLocalKey) + const verifierOptions = mergeOptionsWithKey(localVerifyOptions, secretOrPublicKey, hasStaticPublicKey) const localVerifier = getVerifier(verifierOptions, useGlobalOptions) const verifyResult = localVerifier(token) if (verifyResult && typeof verifyResult.then === 'function') {