VYPR
High severity7.4GHSA Advisory· Published May 18, 2026· Updated May 18, 2026

ruby-jwt: Empty-key HMAC bypass; cross-language sibling of CVE-2026-44351

CVE-2026-45363

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

1

Patches

1
db560b769a07

Merge commit from fork

https://github.com/jwt/ruby-jwtJoakim AntmanMay 13, 2026via ghsa
6 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

4

News mentions

0

No linked articles in our index yet.