ruby-jwt: Empty-key HMAC bypass; cross-language sibling of CVE-2026-44351
Description
JWT.decode(token, '', true, algorithm: 'HS256') accepts an attacker-forged token. OpenSSL::HMAC.digest('SHA256', '', payload) returns a valid digest under an empty key, and no raise InvalidKeyError if key.empty? precondition exists in the HMAC algorithm.
JWT.decode(token, "", true, algorithm: 'HS256')
-> JWA::Hmac.verify(verification_key: "", ...)
-> OpenSSL::HMAC.digest('SHA256', "", signing_input) == signature
The same path is reached when a keyfinder block or key_finder: argument returns "", nil, or an array containing nil for an unknown key. JWT::Decode#find_key only rejects literal nil and empty arrays, and JWT::JWA::Hmac silently coerces nil to "" (signing_key ||= '') before signing.
JWT.decode(token, nil, true, algorithms: ['HS256']) { |_h| "" }
-> find_key returns "" # "" && !Array("").empty? == true
-> JWA::Hmac.verify(verification_key: "", ...)
-> verifies
Common application patterns that produce the unsafe value: redis.get("kid:#{kid}").to_s, ORM string columns with default: '', ENV['SECRET'] || '', Hash.new('') lookups, [primary, fallback] where fallback may be nil. Applications passing a non-empty static key:, or whose keyfinder returns nil / raises on miss, are not affected.
The existing enforce_hmac_key_length option would block this but defaults to false. On OpenSSL ≥ 3.5 the empty-key HMAC.digest call no longer raises, so the OpenSSL-3.0 rescue in JWA::Hmac#sign does not fire.
Affects HS256/HS384/HS512 via both JWT.decode (positional key and block keyfinder) and JWT::EncodedToken#verify_signature!(key_finder:)
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
An empty or nil HMAC secret in ruby-jwt allows forged tokens to be verified as valid, enabling authentication bypass.
Vulnerability
CVE-2026-45363 affects the ruby-jwt gem (the jwt Ruby library) when using HMAC algorithms (HS256, HS384, HS512). The vulnerability occurs when JWT.decode is called with an empty string ("") or nil as the HMAC secret, or when a keyfinder block or key_finder: argument returns an empty string. JWT::JWA::Hmac silently coerces nil to "", and OpenSSL::HMAC.digest('SHA256', "", payload) returns a valid digest under an empty key without raising an error. This affects versions prior to 3.2.0. The enforce_hmac_key_length option exists but defaults to false. Even when passing a non-empty secret, common application patterns (e.g., redis.get("kid:#{kid}").to_s, ENV['SECRET'] || '') can inadvertently produce an empty string, making the application vulnerable [1][2][4].
Exploitation
An attacker can forge a JWT token by signing the payload with an empty key (""). When the application passes an empty secret to JWT.decode (or the keyfinder returns ""), the forged token's signature matches the expected signature, and verification succeeds. The attacker does not need any special network position beyond being able to present the token to the application. No user interaction or race condition is required [1][2][4].
Impact
Successful exploitation allows an attacker to forge a valid JWT token that the application will trust. Depending on how the application uses the decoded payload, this can lead to authentication bypass, privilege escalation, or other unauthorized actions. The impact is limited to HMAC algorithms; RSASSA, ECDSA, and other asymmetric algorithms are not affected [1][2][4].
Mitigation
The vulnerability is fixed in ruby-jwt version 3.2.0, released on 2026-05-18 [3]. The fix introduces a check that raises JWT::DecodeError ('HMAC key expected to be a String') when the secret is empty or nil, preventing the HMAC verification from reaching OpenSSL [2]. Users should upgrade to 3.2.0 or later. Workarounds include enabling enforce_hmac_key_length or ensuring that all code paths that supply HMAC secrets never pass an empty string. If you cannot immediately update, audit keyfinder blocks and key_finder: arguments to guarantee non-empty return values [4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
16 files changed · +47 −109
CHANGELOG.md+3 −4 modified@@ -1,20 +1,19 @@ # Changelog -## [v3.1.3](https://github.com/jwt/ruby-jwt/tree/v3.1.3) (NEXT) +## [v3.2.0](https://github.com/jwt/ruby-jwt/tree/v3.2.0) (2026-05-13) -[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.1.2...v3.1.3) +[Full Changelog](https://github.com/jwt/ruby-jwt/compare/v3.1.2...v3.2.0) **Features:** - Add `enforce_hmac_key_length` configuration option [#716](https://github.com/jwt/ruby-jwt/pull/716) - ([@304](https://github.com/304)) -- Your contribution here **Fixes and enhancements:** +- Reject `nil` and empty HMAC keys when signing and verifying ([CVE-2026-45363](https://www.cve.org/CVERecord?id=CVE-2026-45363) / [GHSA-c32j-vqhx-rx3x](https://github.com/jwt/ruby-jwt/security/advisories/GHSA-c32j-vqhx-rx3x)) - Fix compatibility with the openssl 4.0 gem [#706](https://github.com/jwt/ruby-jwt/pull/706) - Test against Ruby 4.0 on CI [#707](https://github.com/jwt/ruby-jwt/pull/707) - Fix type error when header is not a JSON object [#715](https://github.com/jwt/ruby-jwt/pull/715) - ([@304](https://github.com/304)) -- Your contribution here ## [v3.1.2](https://github.com/jwt/ruby-jwt/tree/v3.1.2) (2025-06-28)
lib/jwt/jwa/hmac.rb+9 −12 modified@@ -21,25 +21,17 @@ def initialize(alg, digest) end def sign(data:, signing_key:) - signing_key ||= '' - raise_verify_error!('HMAC key expected to be a String') unless signing_key.is_a?(String) - + ensure_valid_key!(signing_key) validate_key_length!(signing_key) OpenSSL::HMAC.digest(digest.new, signing_key, data) - rescue OpenSSL::HMACError => e - raise_verify_error!('OpenSSL 3.0 does not support nil or empty hmac_secret') if signing_key == '' && e.message == 'EVP_PKEY_new_mac_key: malloc failure' - - raise e end def verify(data:, signature:, verification_key:) - validation_key = verification_key || '' - raise_verify_error!('HMAC key expected to be a String') unless validation_key.is_a?(String) - - validate_key_length!(validation_key) + ensure_valid_key!(verification_key) + validate_key_length!(verification_key) - SecurityUtils.secure_compare(signature, sign(data: data, signing_key: verification_key)) + SecurityUtils.secure_compare(signature, OpenSSL::HMAC.digest(digest.new, verification_key, data)) end register_algorithm(new('HS256', OpenSSL::Digest::SHA256)) @@ -50,6 +42,11 @@ def verify(data:, signature:, verification_key:) attr_reader :digest + def ensure_valid_key!(key) + raise_verify_error!('HMAC key expected to be a String') unless key.is_a?(String) + raise_verify_error!('HMAC key cannot be empty') if key.empty? + end + def validate_key_length!(key) return unless JWT.configuration.decode.enforce_hmac_key_length
lib/jwt/version.rb+2 −10 modified@@ -15,8 +15,8 @@ def self.gem_version # Version constants module VERSION MAJOR = 3 - MINOR = 1 - TINY = 3 + MINOR = 2 + TINY = 0 PRE = nil STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') @@ -32,14 +32,6 @@ def self.openssl_3? true if 3 * 0x10000000 <= OpenSSL::OPENSSL_VERSION_NUMBER end - # Checks if there is an OpenSSL 3 HMAC empty key regression. - # - # @return [Boolean] true if there is an OpenSSL 3 HMAC empty key regression, false otherwise. - # @api private - def self.openssl_3_hmac_empty_key_regression? - openssl_3? && openssl_version <= ::Gem::Version.new('3.0.0') - end - # Returns the OpenSSL version. # # @return [Gem::Version] the OpenSSL version.
spec/integration/readme_examples_spec.rb+0 −12 modified@@ -28,18 +28,6 @@ ] end - it 'decodes with HMAC algorithm without secret key' do - pending 'Different behaviour on OpenSSL 3.0 (https://github.com/openssl/openssl/issues/13089)' if JWT.openssl_3_hmac_empty_key_regression? - token = JWT.encode payload, nil, 'HS256' - decoded_token = JWT.decode token, nil, false - - expect(token).to eq 'eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjoidGVzdCJ9.pVzcY2dX8JNM3LzIYeP2B1e1Wcpt1K3TWVvIYSF4x-o' - expect(decoded_token).to eq [ - { 'data' => 'test' }, - { 'alg' => 'HS256' } - ] - end - it 'RSA' do rsa_private = OpenSSL::PKey::RSA.generate 2048 rsa_public = rsa_private.public_key
spec/jwt/jwa/hmac_spec.rb+33 −49 modified@@ -12,68 +12,31 @@ it { is_expected.to eq(valid_signature) } end - # Address OpenSSL 3.0 errors with empty hmac_secret - https://github.com/jwt/ruby-jwt/issues/526 + # GHSA-c32j-vqhx-rx3x: empty/nil keys must be rejected before reaching OpenSSL, + # so a forged token signed with "" cannot verify. context 'when nil hmac_secret is passed' do let(:hmac_secret) { nil } - context 'when OpenSSL 3.0 raises a malloc failure' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('EVP_PKEY_new_mac_key: malloc failure')) - end - - it 'raises JWT::DecodeError' do - expect { subject }.to raise_error(JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret') - end - end - context 'when OpenSSL raises any other error' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('Another Random Error')) - end - - it 'raises the original error' do - expect { subject }.to raise_error(OpenSSL::HMACError, 'Another Random Error') - end + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') end - context 'when other versions of openssl do not raise an exception' do - let(:response) { Base64.decode64("Q7DO+ZJl+eNMEOqdNQGSbSezn1fG1nRWHYuiNueoGfs=\n") } - before do - allow(OpenSSL::HMAC).to receive(:digest).and_return(response) - end - - it { is_expected.to eql(response) } + it 'does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError) end end context 'when blank hmac_secret is passed' do let(:hmac_secret) { '' } - context 'when OpenSSL 3.0 raises a malloc failure' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('EVP_PKEY_new_mac_key: malloc failure')) - end - - it 'raises JWT::DecodeError' do - expect { subject }.to raise_error(JWT::DecodeError, 'OpenSSL 3.0 does not support nil or empty hmac_secret') - end - end - context 'when OpenSSL raises any other error' do - before do - allow(OpenSSL::HMAC).to receive(:digest).and_raise(OpenSSL::HMACError.new('Another Random Error')) - end - - it 'raises the original error' do - expect { subject }.to raise_error(OpenSSL::HMACError, 'Another Random Error') - end + it 'raises JWT::DecodeError' do + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key cannot be empty') end - context 'when other versions of openssl do not raise an exception' do - let(:response) { Base64.decode64("Q7DO+ZJl+eNMEOqdNQGSbSezn1fG1nRWHYuiNueoGfs=\n") } - before do - allow(OpenSSL::HMAC).to receive(:digest).and_return(response) - end - - it { is_expected.to eql(response) } + it 'does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError) end end @@ -160,6 +123,27 @@ end end + # GHSA-c32j-vqhx-rx3x + context 'when verification_key is nil' do + let(:signature) { valid_signature } + let(:hmac_secret) { nil } + + it 'raises error and does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key expected to be a String') + end + end + + context 'when verification_key is empty' do + let(:signature) { valid_signature } + let(:hmac_secret) { '' } + + it 'raises error and does not call OpenSSL::HMAC.digest' do + expect(OpenSSL::HMAC).not_to receive(:digest) + expect { subject }.to raise_error(JWT::DecodeError, 'HMAC key cannot be empty') + end + end + context 'when enforce_hmac_key_length is enabled' do before do JWT.configuration.decode.enforce_hmac_key_length = true
spec/jwt/jwt_spec.rb+0 −22 modified@@ -602,20 +602,6 @@ end end - context 'when hmac algorithm is used without secret key' do - it 'encodes payload' do - pending 'Different behaviour on OpenSSL 3.0 (https://github.com/openssl/openssl/issues/13089)' if JWT.openssl_3_hmac_empty_key_regression? - payload = { a: 1, b: 'b' } - - token = JWT.encode(payload, '', 'HS256') - - expect do - token_without_secret = JWT.encode(payload, nil, 'HS256') - expect(token).to eq(token_without_secret) - end.not_to raise_error - end - end - context 'algorithm case insensitivity' do let(:payload) { { 'a' => 1, 'b' => 'b' } } @@ -732,14 +718,6 @@ end end - describe 'when token signed with nil and decoded with nil' do - let(:no_key_token) { JWT.encode(payload, nil, 'HS512') } - it 'raises JWT::DecodeError' do - pending 'Different behaviour on OpenSSL 3.0 (https://github.com/openssl/openssl/issues/13089)' if JWT.openssl_3_hmac_empty_key_regression? - expect { JWT.decode(no_key_token, nil, true, algorithms: 'HS512') }.to raise_error(JWT::DecodeError, 'No verification key available') - end - end - context 'when token ends with a newline char' do let(:token) { "#{JWT.encode(payload, 'secret', 'HS256')}\n" } it 'raises an error' do
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
4News mentions
0No linked articles in our index yet.