JWT algorithm confusion in Hono JWK Auth Middleware when JWK lacks "alg" (untrusted header.alg fallback)
Description
Hono is a Web application framework that provides support for any JavaScript runtime. Prior to 4.11.4, there is a flaw in Hono’s JWK/JWKS JWT verification middleware allowed the algorithm specified in the JWT header to influence signature verification when the selected JWK did not explicitly define an algorithm. This could enable JWT algorithm confusion and, in certain configurations, allow forged tokens to be accepted. The JWK/JWKS JWT verification middleware has been updated to require an explicit allowlist of asymmetric algorithms when verifying tokens. The middleware no longer derives the verification algorithm from untrusted JWT header values. This vulnerability is fixed in 4.11.4.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
hononpm | < 4.11.4 | 4.11.4 |
Affected products
1Patches
17 files changed · +673 −30
src/middleware/jwk/index.test.ts+136 −16 modified@@ -35,9 +35,9 @@ describe('JWK', () => { afterAll(() => server.close()) describe('verifyWithJwks', () => { - it('Should throw error on missing options', async () => { + it('Should throw error on missing keys/jwks_uri options', async () => { const credential = await Jwt.sign({ message: 'hello world' }, test_keys.private_keys[0]) - await expect(verifyWithJwks(credential, {})).rejects.toThrow( + await expect(verifyWithJwks(credential, { allowedAlgorithms: ['RS256'] })).rejects.toThrow( 'verifyWithJwks requires options for either "keys" or "jwks_uri" or both' ) }) @@ -52,7 +52,7 @@ describe('JWK', () => { const app = new Hono() - app.use('/backend-auth-or-anon/*', jwk({ keys: verify_keys, allow_anon: true })) + app.use('/backend-auth-or-anon/*', jwk({ keys: verify_keys, allow_anon: true, alg: ['RS256'] })) app.get('/backend-auth-or-anon/*', (c) => { handlerExecuted = true @@ -105,10 +105,10 @@ describe('JWK', () => { const app = new Hono() - app.use('/auth-with-keys/*', jwk({ keys: verify_keys })) - app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys })) + app.use('/auth-with-keys/*', jwk({ keys: verify_keys, alg: ['RS256'] })) + app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys, alg: ['RS256'] })) app.use('/auth-with-keys-nested/*', async (c, next) => { - const auth = jwk({ keys: verify_keys }) + const auth = jwk({ keys: verify_keys, alg: ['RS256'] }) return auth(c, next) }) app.use( @@ -119,37 +119,43 @@ describe('JWK', () => { const data = await response.json() return data.keys }, + alg: ['RS256'], }) ) app.use( '/auth-with-jwks_uri/*', jwk({ jwks_uri: 'http://localhost/.well-known/jwks.json', + alg: ['RS256'], }) ) app.use( '/auth-with-keys-and-jwks_uri/*', jwk({ keys: verify_keys, jwks_uri: () => 'http://localhost/.well-known/jwks.json', + alg: ['RS256'], }) ) app.use( '/auth-with-missing-jwks_uri/*', jwk({ jwks_uri: 'http://localhost/.well-known/missing-jwks.json', + alg: ['RS256'], }) ) app.use( '/auth-with-404-jwks_uri/*', jwk({ jwks_uri: 'http://localhost/.well-known/404-jwks.json', + alg: ['RS256'], }) ) app.use( '/auth-with-bad-jwks_uri/*', jwk({ jwks_uri: 'http://localhost/.well-known/bad-jwks.json', + alg: ['RS256'], }) ) @@ -200,6 +206,7 @@ describe('JWK', () => { }) it('Should throw an error if the middleware is missing both keys and jwks_uri (empty)', async () => { + // @ts-expect-error - Testing runtime error with missing required alg option expect(() => app.use('/auth-with-empty-middleware/*', jwk({}))).toThrow( 'JWK auth middleware requires options for either "keys" or "jwks_uri"' ) @@ -210,7 +217,9 @@ describe('JWK', () => { importKey: undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) - expect(() => app.use('/auth-with-bad-env/*', jwk({ keys: verify_keys }))).toThrow() + expect(() => + app.use('/auth-with-bad-env/*', jwk({ keys: verify_keys, alg: ['RS256'] })) + ).toThrow() subtleSpy.mockRestore() }) @@ -443,7 +452,10 @@ describe('JWK', () => { const app = new Hono() - app.use('/auth-with-keys/*', jwk({ keys: verify_keys, headerName: 'x-custom-auth-header' })) + app.use( + '/auth-with-keys/*', + jwk({ keys: verify_keys, headerName: 'x-custom-auth-header', alg: ['RS256'] }) + ) app.get('/auth-with-keys/*', (c) => { handlerExecuted = true @@ -494,15 +506,22 @@ describe('JWK', () => { const app = new Hono() - app.use('/auth-with-keys/*', jwk({ keys: verify_keys, cookie: 'access_token' })) - app.use('/auth-with-keys-unicode/*', jwk({ keys: verify_keys, cookie: 'access_token' })) + app.use('/auth-with-keys/*', jwk({ keys: verify_keys, cookie: 'access_token', alg: ['RS256'] })) + app.use( + '/auth-with-keys-unicode/*', + jwk({ keys: verify_keys, cookie: 'access_token', alg: ['RS256'] }) + ) app.use( '/auth-with-keys-prefixed/*', - jwk({ keys: verify_keys, cookie: { key: 'access_token', prefixOptions: 'host' } }) + jwk({ + keys: verify_keys, + cookie: { key: 'access_token', prefixOptions: 'host' }, + alg: ['RS256'], + }) ) app.use( '/auth-with-keys-unprefixed/*', - jwk({ keys: verify_keys, cookie: { key: 'access_token' } }) + jwk({ keys: verify_keys, cookie: { key: 'access_token' }, alg: ['RS256'] }) ) app.get('/auth-with-keys/*', (c) => { @@ -637,13 +656,18 @@ describe('JWK', () => { app.use( '/auth-with-signed-cookie/*', - jwk({ keys: verify_keys, cookie: { key: 'access_token', secret: test_secret } }) + jwk({ + keys: verify_keys, + cookie: { key: 'access_token', secret: test_secret }, + alg: ['RS256'], + }) ) app.use( '/auth-with-signed-with-prefix-options-cookie/*', jwk({ keys: verify_keys, cookie: { key: 'access_token', secret: test_secret, prefixOptions: 'host' }, + alg: ['RS256'], }) ) @@ -721,7 +745,7 @@ describe('JWK', () => { describe('Error handling with `cause`', () => { const app = new Hono() - app.use('/auth-with-keys/*', jwk({ keys: verify_keys })) + app.use('/auth-with-keys/*', jwk({ keys: verify_keys, alg: ['RS256'] })) app.get('/auth-with-keys/*', (c) => c.text('Authorized')) app.onError((e, c) => { @@ -761,10 +785,10 @@ describe('JWK', () => { const app = new Hono() - app.use('/auth-with-keys-default/*', jwk({ keys: verify_keys })) + app.use('/auth-with-keys-default/*', jwk({ keys: verify_keys, alg: ['RS256'] })) app.use( '/auth-with-keys-and-issuer/*', - jwk({ keys: verify_keys, verification: { iss: 'http://issuer.test' } }) + jwk({ keys: verify_keys, verification: { iss: 'http://issuer.test' }, alg: ['RS256'] }) ) app.get('/auth-with-keys-default/*', (c) => { @@ -874,4 +898,100 @@ describe('JWK', () => { expect(handlerExecuted).toBeFalsy() }) }) + + describe('Algorithm whitelist (options.alg)', () => { + let handlerExecuted: boolean + + beforeEach(() => { + handlerExecuted = false + }) + + const app = new Hono() + + // Only allow RS256 + app.use('/auth-whitelist-rs256/*', jwk({ keys: verify_keys, alg: ['RS256'] })) + app.get('/auth-whitelist-rs256/*', (c) => { + handlerExecuted = true + return c.json(c.get('jwtPayload')) + }) + + // Allow multiple algorithms + app.use('/auth-whitelist-multi/*', jwk({ keys: verify_keys, alg: ['RS256', 'ES256'] })) + app.get('/auth-whitelist-multi/*', (c) => { + handlerExecuted = true + return c.json(c.get('jwtPayload')) + }) + + // Note: Test for "no whitelist" was removed because alg is now required. + // This is a breaking change that enforces explicit algorithm specification for security. + + it('Should authorize RS256 token when RS256 is in whitelist', async () => { + const payload = { message: 'hello world' } + const credential = await Jwt.sign(payload, test_keys.private_keys[0]) // RS256 key + const req = new Request('http://localhost/auth-whitelist-rs256/a') + req.headers.set('Authorization', `Bearer ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual(payload) + expect(handlerExecuted).toBeTruthy() + }) + + it('Should reject token when algorithm is not in whitelist', async () => { + // Create a token with ES256 algorithm manually + const kid = 'hono-test-kid-1' // Use existing kid but header will have different alg + const payload = { message: 'hello world' } + + // Generate ES256 key pair for signing + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256', + }, + true, + ['sign', 'verify'] + ) + + // Create JWT with ES256 + const header = { alg: 'ES256', typ: 'JWT', kid } + const encode = (obj: object) => + encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing( + keyPair.privateKey, + 'ES256', + utf8Encoder.encode(signingInput) + ) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + const url = 'http://localhost/auth-whitelist-rs256/a' + const req = new Request(url) + req.headers.set('Authorization', `Bearer ${token}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toMatch(/token verification failure/) + expect(handlerExecuted).toBeFalsy() + }) + + it('Should authorize RS256 token when multiple algorithms are in whitelist', async () => { + const payload = { message: 'hello world' } + const credential = await Jwt.sign(payload, test_keys.private_keys[0]) // RS256 key + const req = new Request('http://localhost/auth-whitelist-multi/a') + req.headers.set('Authorization', `Bearer ${credential}`) + const res = await app.request(req) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.json()).toEqual(payload) + expect(handlerExecuted).toBeTruthy() + }) + + // Note: Test for "no whitelist" was removed because alg is now required. + // This is a breaking change that enforces explicit algorithm specification for security. + }) })
src/middleware/jwk/jwk.ts+9 −1 modified@@ -10,6 +10,7 @@ import type { MiddlewareHandler } from '../../types' import type { CookiePrefixOptions } from '../../utils/cookie' import { Jwt } from '../../utils/jwt' import '../../context' +import type { AsymmetricAlgorithm } from '../../utils/jwt/jwa' import type { HonoJsonWebKey } from '../../utils/jwt/jws' import type { VerifyOptions } from '../../utils/jwt/jwt' @@ -24,6 +25,7 @@ import type { VerifyOptions } from '../../utils/jwt/jwt' * @param {boolean} [options.allow_anon] - If set to `true`, the middleware allows requests without a token to proceed without authentication. * @param {string} [options.cookie] - If set, the middleware attempts to retrieve the token from a cookie with these options (optionally signed) only if no token is found in the header. * @param {string} [options.headerName='Authorization'] - The name of the header to look for the JWT token. Default is 'Authorization'. + * @param {AsymmetricAlgorithm[]} options.alg - An array of allowed asymmetric algorithms for JWT verification. Only tokens signed with these algorithms will be accepted. * @param {RequestInit} [init] - Optional init options for the `fetch` request when retrieving JWKS from a URI. * @param {VerifyOptions} [options.verification] - Additional options for JWK payload verification. * @returns {MiddlewareHandler} The middleware handler function. @@ -54,6 +56,8 @@ export const jwk = ( headerName?: string + alg: AsymmetricAlgorithm[] + verification?: VerifyOptions }, init?: RequestInit @@ -132,7 +136,11 @@ export const jwk = ( const keys = typeof options.keys === 'function' ? await options.keys(ctx) : options.keys const jwks_uri = typeof options.jwks_uri === 'function' ? await options.jwks_uri(ctx) : options.jwks_uri - payload = await Jwt.verifyWithJwks(token, { keys, jwks_uri, verification: verifyOpts }, init) + payload = await Jwt.verifyWithJwks( + token, + { keys, jwks_uri, verification: verifyOpts, allowedAlgorithms: options.alg }, + init + ) } catch (e) { cause = e }
src/utils/jwt/jwa.test.ts+62 −0 modified@@ -1,4 +1,5 @@ import { AlgorithmTypes } from './jwa' +import type { AsymmetricAlgorithm, SymmetricAlgorithm, SignatureAlgorithm } from './jwa' describe('Types', () => { it('AlgorithmTypes', () => { @@ -21,4 +22,65 @@ describe('Types', () => { expect(undefined as AlgorithmTypes).toBe(undefined) expect('' as AlgorithmTypes).toBe('') }) + + it('SymmetricAlgorithm type should only include HMAC algorithms', () => { + // These should be valid SymmetricAlgorithm values + const hs256: SymmetricAlgorithm = 'HS256' + const hs384: SymmetricAlgorithm = 'HS384' + const hs512: SymmetricAlgorithm = 'HS512' + + expect(hs256).toBe('HS256') + expect(hs384).toBe('HS384') + expect(hs512).toBe('HS512') + + // Type-level test: these would cause compile errors if uncommented + // const rs256: SymmetricAlgorithm = 'RS256' // Error: Type '"RS256"' is not assignable to type 'SymmetricAlgorithm' + }) + + it('AsymmetricAlgorithm type should only include asymmetric algorithms', () => { + // These should be valid AsymmetricAlgorithm values + const asymmetricAlgs: AsymmetricAlgorithm[] = [ + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'ES256', + 'ES384', + 'ES512', + 'EdDSA', + ] + + expect(asymmetricAlgs).toHaveLength(10) + + // Verify all asymmetric algorithms are included + expect(asymmetricAlgs).toContain('RS256') + expect(asymmetricAlgs).toContain('ES256') + expect(asymmetricAlgs).toContain('EdDSA') + + // Type-level test: these would cause compile errors if uncommented + // const hs256: AsymmetricAlgorithm = 'HS256' // Error: Type '"HS256"' is not assignable to type 'AsymmetricAlgorithm' + }) + + it('SignatureAlgorithm type should include all algorithms', () => { + // SignatureAlgorithm should include both symmetric and asymmetric algorithms + const allAlgs: SignatureAlgorithm[] = [ + 'HS256', + 'HS384', + 'HS512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'ES256', + 'ES384', + 'ES512', + 'EdDSA', + ] + + expect(allAlgs).toHaveLength(13) + }) })
src/utils/jwt/jwa.ts+14 −0 modified@@ -21,3 +21,17 @@ export enum AlgorithmTypes { } export type SignatureAlgorithm = keyof typeof AlgorithmTypes + +export type SymmetricAlgorithm = 'HS256' | 'HS384' | 'HS512' + +export type AsymmetricAlgorithm = + | 'RS256' + | 'RS384' + | 'RS512' + | 'PS256' + | 'PS384' + | 'PS512' + | 'ES256' + | 'ES384' + | 'ES512' + | 'EdDSA'
src/utils/jwt/jwt.test.ts+411 −11 modified@@ -7,9 +7,11 @@ import * as JWT from './jwt' import { verifyWithJwks } from './jwt' import { JwtAlgorithmMismatch, + JwtAlgorithmNotAllowed, JwtAlgorithmNotImplemented, JwtAlgorithmRequired, JwtPayloadRequiresAud, + JwtSymmetricAlgorithmNotAllowed, JwtTokenAudience, JwtTokenExpired, JwtTokenInvalid, @@ -833,43 +835,441 @@ describe('JWT', () => { describe('verifyWithJwks header.alg fallback', () => { it('Should use header.alg as fallback when matchingKey.alg is missing', async () => { - // Setup: Create a JWT signed with HS384 (different from default HS256) + // Setup: Create a JWT signed with RS256 (asymmetric algorithm) const payload = { message: 'hello world' } - const headerAlg = 'HS384' // Non-default value - const secret = 'secret' + const headerAlg = 'RS256' const kid = 'dummy' - // Create JWT (signed with HS384) + // Generate RSA key pair + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + ) + + // Create JWT (signed with RS256) const header = { alg: headerAlg, typ: 'JWT', kid } const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) const encodedHeader = encode(header) const encodedPayload = encode(payload) const signingInput = `${encodedHeader}.${encodedPayload}` - // Use signing function from jws.ts instead of createHmac directly - const signatureBuffer = await signing(secret, headerAlg, utf8Encoder.encode(signingInput)) + // Sign with private key + const signatureBuffer = await signing( + keyPair.privateKey, + headerAlg, + utf8Encoder.encode(signingInput) + ) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + // Export public key as JWK without alg property + const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + const keyWithoutAlg = { + ...jwk, + kid, + use: 'sig', + } + delete keyWithoutAlg.alg // intentionally omit alg + + const keys = [keyWithoutAlg] + + // Execute: Verify the JWT token signed with RS256 with allowedAlgorithms required + const result = await verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256'] }) + + // If verification succeeds, it means header.alg was used + expect(result).toEqual(payload) + }) +}) + +describe('verifyWithJwks security', () => { + it('Should reject symmetric algorithm HS256', async () => { + const payload = { message: 'hello world' } + const secret = 'secret' + const kid = 'dummy' + + // Create JWT (signed with HS256) + const header = { alg: 'HS256', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing(secret, 'HS256', utf8Encoder.encode(signingInput)) const signature = encodeBase64Url(signatureBuffer) const token = `${encodedHeader}.${encodedPayload}.${signature}` - // Create a key without alg property const keys = [ { kty: 'oct', kid, k: encodeBase64Url(utf8Encoder.encode(secret).buffer), use: 'sig', - // alg is intentionally omitted + alg: 'HS256', }, ] - // Execute: Verify the JWT token signed with HS384 - const result = await verifyWithJwks(token, { keys }) + // HS256 is rejected before allowedAlgorithms check (symmetric algorithm rejection) + await expect(verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256'] })).rejects.toThrow( + JwtSymmetricAlgorithmNotAllowed + ) + }) - // If verification succeeds, it means header.alg was used + it('Should reject symmetric algorithm HS384', async () => { + const payload = { message: 'hello world' } + const secret = 'secret' + const kid = 'dummy' + + const header = { alg: 'HS384', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing(secret, 'HS384', utf8Encoder.encode(signingInput)) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + const keys = [ + { + kty: 'oct', + kid, + k: encodeBase64Url(utf8Encoder.encode(secret).buffer), + use: 'sig', + alg: 'HS384', + }, + ] + + // HS384 is rejected before allowedAlgorithms check (symmetric algorithm rejection) + await expect(verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256'] })).rejects.toThrow( + JwtSymmetricAlgorithmNotAllowed + ) + }) + + it('Should reject symmetric algorithm HS512', async () => { + const payload = { message: 'hello world' } + const secret = 'secret' + const kid = 'dummy' + + const header = { alg: 'HS512', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing(secret, 'HS512', utf8Encoder.encode(signingInput)) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + const keys = [ + { + kty: 'oct', + kid, + k: encodeBase64Url(utf8Encoder.encode(secret).buffer), + use: 'sig', + alg: 'HS512', + }, + ] + + // HS512 is rejected before allowedAlgorithms check (symmetric algorithm rejection) + await expect(verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256'] })).rejects.toThrow( + JwtSymmetricAlgorithmNotAllowed + ) + }) + + it('Should reject algorithm mismatch between JWK and JWT header', async () => { + const payload = { message: 'hello world' } + const kid = 'dummy' + + // Generate RS256 key pair + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + ) + + // Create JWT with RS384 in header (mismatch with RS256 key) + const header = { alg: 'RS384', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + // Sign with RS256 key (but header says RS384) + const signatureBuffer = await signing( + keyPair.privateKey, + 'RS256', + utf8Encoder.encode(signingInput) + ) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + // Export public key as JWK with RS256 alg + const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + const keyWithAlg = { + ...jwk, + kid, + use: 'sig', + alg: 'RS256', // JWK says RS256, but header says RS384 + } + + const keys = [keyWithAlg] + + // RS384 in header doesn't match RS256 in JWK alg field + await expect(verifyWithJwks(token, { keys, allowedAlgorithms: ['RS384'] })).rejects.toThrow( + JwtAlgorithmMismatch + ) + }) + + it('Should allow asymmetric algorithm RS256 with matching alg', async () => { + const payload = { message: 'hello world' } + const kid = 'dummy' + + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + ) + + const header = { alg: 'RS256', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing( + keyPair.privateKey, + 'RS256', + utf8Encoder.encode(signingInput) + ) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + const keyWithAlg = { + ...jwk, + kid, + use: 'sig', + alg: 'RS256', + } + + const keys = [keyWithAlg] + + const result = await verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256'] }) expect(result).toEqual(payload) }) + + it('Should reject algorithm confusion attack (HS256 with RSA public key)', async () => { + // This test simulates the algorithm confusion attack where an attacker + // tries to use HS256 with a public RSA key as the HMAC secret + const payload = { message: 'hello world' } + const kid = 'dummy' + + // Generate RSA key pair (normally used for RS256) + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + ) + + // Attacker creates a JWT with HS256 in header + const header = { alg: 'HS256', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + // Export public key as JWK (which would be used in a real attack) + const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + + // Attacker would sign with the public key bytes as HMAC secret + // We don't need to actually sign for this test - just verify rejection + const signatureBuffer = await signing('fake-secret', 'HS256', utf8Encoder.encode(signingInput)) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + // JWK has RS256 alg, but JWT header has HS256 (confusion attack) + const keyWithAlg = { + ...jwk, + kid, + use: 'sig', + alg: 'RS256', + } + + const keys = [keyWithAlg] + + // Should reject because HS256 is a symmetric algorithm (checked before allowedAlgorithms) + await expect(verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256'] })).rejects.toThrow( + JwtSymmetricAlgorithmNotAllowed + ) + }) }) + +describe('verifyWithJwks algorithm whitelist', () => { + it('Should reject algorithm not in whitelist', async () => { + const payload = { message: 'hello world' } + const kid = 'dummy' + + // Generate RS256 key pair + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + ) + + // Create JWT with RS256 + const header = { alg: 'RS256', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing( + keyPair.privateKey, + 'RS256', + utf8Encoder.encode(signingInput) + ) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + const keyWithAlg = { + ...jwk, + kid, + use: 'sig', + alg: 'RS256', + } + + const keys = [keyWithAlg] + + // RS256 is not in the whitelist (only ES256 is allowed) + await expect(verifyWithJwks(token, { keys, allowedAlgorithms: ['ES256'] })).rejects.toThrow( + JwtAlgorithmNotAllowed + ) + }) + + it('Should accept algorithm in whitelist', async () => { + const payload = { message: 'hello world' } + const kid = 'dummy' + + // Generate RS256 key pair + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + ) + + // Create JWT with RS256 + const header = { alg: 'RS256', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing( + keyPair.privateKey, + 'RS256', + utf8Encoder.encode(signingInput) + ) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + const keyWithAlg = { + ...jwk, + kid, + use: 'sig', + alg: 'RS256', + } + + const keys = [keyWithAlg] + + // RS256 is in the whitelist + const result = await verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256', 'ES256'] }) + expect(result).toEqual(payload) + }) + + // Note: Tests for "whitelist not specified" and "empty whitelist" were removed + // because allowedAlgorithms is now required (not optional). + // This is a breaking change that enforces explicit algorithm specification for security. + + it('Should reject symmetric algorithm (HS256) in JWT header', async () => { + // This test verifies that symmetric algorithms are rejected even when + // using verifyWithJwks with asymmetric algorithm whitelist. + // Note: HS256 cannot be added to allowedAlgorithms due to type constraints (AsymmetricAlgorithm[]) + const payload = { message: 'hello world' } + const secret = 'secret' + const kid = 'dummy' + + // Create JWT with HS256 + const header = { alg: 'HS256', typ: 'JWT', kid } + const encode = (obj: object) => encodeBase64Url(utf8Encoder.encode(JSON.stringify(obj)).buffer) + const encodedHeader = encode(header) + const encodedPayload = encode(payload) + const signingInput = `${encodedHeader}.${encodedPayload}` + + const signatureBuffer = await signing(secret, 'HS256', utf8Encoder.encode(signingInput)) + const signature = encodeBase64Url(signatureBuffer) + + const token = `${encodedHeader}.${encodedPayload}.${signature}` + + const keys = [ + { + kty: 'oct', + kid, + k: encodeBase64Url(utf8Encoder.encode(secret).buffer), + use: 'sig', + alg: 'HS256', + }, + ] + + // HS256 in JWT header should be rejected as symmetric algorithm + // (symmetric algorithm check happens before allowedAlgorithms check) + await expect(verifyWithJwks(token, { keys, allowedAlgorithms: ['RS256'] })).rejects.toThrow( + JwtSymmetricAlgorithmNotAllowed + ) + }) +}) + async function exportPEMPrivateKey(key: CryptoKey): Promise<string> { const exported = await crypto.subtle.exportKey('pkcs8', key) const pem = `-----BEGIN PRIVATE KEY-----\n${encodeBase64(exported)}\n-----END PRIVATE KEY-----`
src/utils/jwt/jwt.ts+27 −2 modified@@ -6,15 +6,17 @@ import { decodeBase64Url, encodeBase64Url } from '../../utils/encode' import { AlgorithmTypes } from './jwa' -import type { SignatureAlgorithm } from './jwa' +import type { AsymmetricAlgorithm, SignatureAlgorithm, SymmetricAlgorithm } from './jwa' import { signing, verifying } from './jws' import type { HonoJsonWebKey, SignatureKey } from './jws' import { JwtAlgorithmMismatch, + JwtAlgorithmNotAllowed, JwtAlgorithmRequired, JwtHeaderInvalid, JwtHeaderRequiresKid, JwtPayloadRequiresAud, + JwtSymmetricAlgorithmNotAllowed, JwtTokenAudience, JwtTokenExpired, JwtTokenInvalid, @@ -179,12 +181,20 @@ export const verify = async ( return payload } +// Symmetric algorithms that are not allowed for JWK verification +const symmetricAlgorithms: SymmetricAlgorithm[] = [ + AlgorithmTypes.HS256, + AlgorithmTypes.HS384, + AlgorithmTypes.HS512, +] + export const verifyWithJwks = async ( token: string, options: { keys?: HonoJsonWebKey[] jwks_uri?: string verification?: VerifyOptions + allowedAlgorithms: AsymmetricAlgorithm[] }, init?: RequestInit ): Promise<JWTPayload> => { @@ -199,6 +209,16 @@ export const verifyWithJwks = async ( throw new JwtHeaderRequiresKid(header) } + // Reject symmetric algorithms (HS256, HS384, HS512) to prevent algorithm confusion attacks + if (symmetricAlgorithms.includes(header.alg as SymmetricAlgorithm)) { + throw new JwtSymmetricAlgorithmNotAllowed(header.alg) + } + + // Validate against allowed algorithms + if (!options.allowedAlgorithms.includes(header.alg as AsymmetricAlgorithm)) { + throw new JwtAlgorithmNotAllowed(header.alg, options.allowedAlgorithms) + } + if (options.jwks_uri) { const response = await fetch(options.jwks_uri, init) if (!response.ok) { @@ -225,8 +245,13 @@ export const verifyWithJwks = async ( throw new JwtTokenInvalid(token) } + // Verify that JWK's alg matches JWT header's alg when JWK has alg field + if (matchingKey.alg && matchingKey.alg !== header.alg) { + throw new JwtAlgorithmMismatch(matchingKey.alg, header.alg) + } + return await verify(token, matchingKey, { - alg: (matchingKey.alg as SignatureAlgorithm) || header.alg, + alg: header.alg, ...verifyOpts, }) }
src/utils/jwt/types.ts+14 −0 modified@@ -75,6 +75,20 @@ export class JwtHeaderRequiresKid extends Error { } } +export class JwtSymmetricAlgorithmNotAllowed extends Error { + constructor(alg: string) { + super(`symmetric algorithm "${alg}" is not allowed for JWK verification`) + this.name = 'JwtSymmetricAlgorithmNotAllowed' + } +} + +export class JwtAlgorithmNotAllowed extends Error { + constructor(alg: string, allowedAlgorithms: string[]) { + super(`algorithm "${alg}" is not in the allowed list: [${allowedAlgorithms.join(', ')}]`) + this.name = 'JwtAlgorithmNotAllowed' + } +} + export class JwtTokenSignatureMismatched extends Error { constructor(token: string) { super(`token(${token}) signature mismatched`)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-3vhc-576x-3qv4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-22818ghsaADVISORY
- github.com/honojs/hono/commit/190f6e28e2ca85ce3d1f2f54db1310f5f3eab134ghsax_refsource_MISCWEB
- github.com/honojs/hono/security/advisories/GHSA-3vhc-576x-3qv4ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.