ruby-on-railsjwthmac

Double Decodes for HMAC JWTs using kids?


We are trying to use ruby-jwt to encode/decode HS256 signed JWTs using kids to indicate the record in a keystore that represents the key to use, but it seems like we need to do 2 decodes for verification. One decode not using a secret or indicating to ruby-jwt that we want to verify the JWT just so we can scrape the kid out of it, then a second decode that passes in the secret that the kid represents, and the flag to verify the JWT. It's a minor thing and b64 is cheap, but is doing 2 decodes really the approach that needs to be taken for HMAC JWTs with kid in them? Is this a limitation of the library, or a gap in knowledge on how to work with kids with HMAC?

Create JWT with HS256

keys = {'31b88f20-8edd-49fe-a839-57b8f681888c' => 'mysecret'}
payload = {user_id: 99, kid: keys.first.first, exp: 5.years.from_now.to_i}
token = JWT.encode(payload, keys.first.second, 'HS256')

Verify JWT

# Get the kid from the jwt so we can look up what secret to use to verify it by the kid
decoded_jwt = JWT.decode(token, nil, false)
kid = decoded_jwt[0]['kid']
secret = keys[kid]
# decode the jwt again with the secret, and verify it
decoded_jwt = JWT.decode(token, secret, true)
# => [{"user_id"=>99, "kid"=>"31b88f20-8edd-49fe-a839-57b8f681888c", "exp"=>1865946624}, {"alg"=>"HS256"}]

It works but trying to see if there is a better approach. Thanks!


Solution

  • You can avoid the double decoding by using JSON Web Key Set (JWKS).

    To implement this process using JWKS and HMAC you can do so as follows.

    Encoding:

    require 'jwt'
    
    secret = 'mysecret'
    kid = '31b88f20-8edd-49fe-a839-57b8f681888c'
    algorithm = 'HS256'
    
    # Used strings for comparison at the end 
    # Used Time so it did not rely on Rails
    payload = {"user_id" =>  99, "exp" => Time.now.to_i + 10000}
    
    # Encoding pass kid as a header field 
    token = JWT.encode(payload, secret, algorithm , kid: kid)
    #=> "eyJraWQiOiIzMWI4OGYyMC04ZWRkLTQ5ZmUtYTgzOS01N2I4ZjY4MTg4OGMiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5OSwiZXhwIjoxNzA4MTE3ODIzfQ.UArtkNyPx0ri08i6D2t7sLtZ_dlqoDczP7r4ZVPtTGM"
    

    Export Keys:

    Since HMAC is a symmetric key the Key Type (kty) needs to be set to 'oct' See RFC7518 at 6.1, and the Key Value (k) is set to the secret See RFC7518 at 6.4.1

    #Create a JWK by importing the Hash
    jwk = JWT::JWK.new({ kty: 'oct', k: secret, kid: kid, alg: algorithm  })
    
    # JSON Web Key Set for advertising your signing keys
    jwks_hash = JWT::JWK::Set.new(jwk).export
    #=> {:keys=>[{:kty=>"oct", :kid=>"31b88f20-8edd-49fe-a839-57b8f681888c", :alg=>"HS256"}]}
    
    # OR export private keys (Not recommended refer to: https://www.rfc-editor.org/rfc/rfc7517.html#section-9.2)
    
    jwks_hash = JWT::JWK::Set.new(jwk).export(include_private: true)
    #=> {:keys=>[{:kty=>"oct", :k=>"mysecret", :kid=>"31b88f20-8edd-49fe-a839-57b8f681888c", :alg=>"HS256"}]}
    

    Decoding:

    # If you did not export the private secret, if you did this step can be skipped
    jwks_hash[:keys].each {|k| k.merge!(k: secret)}
    
    # Create a JWKS
    jwks = JWT::JWK::Set.new(jwks_hash)
    
    #Decoding
    decoded = JWT.decode(token, nil, true, jwks: jwks)
    #=> [{"user_id"=>1708119080, "exp"=>1708119627}, {"kid"=>"31b88f20-8edd-49fe-a839-57b8f681888c", "alg"=>"HS256"}]
    
    decoded[0] == payload
    #=> true