Verifying JWTs with JWKS in Ruby

In this post, we will discuss a few issues, and their solutions, when working with asymmetrically signed JWTs (JSON Web Tokens).
The post assumes you have a basic working knowledge of JWTs.
The Lowdown (Intro)
When working with JWTs (JSON Web Tokens), it is common to sign them using asymmetric encryption. Asymmetric encryption involves using a public/private key pair, where:
  • the issuer signs the JWT with the private key
  • the receiver verifies the JWT's origin & contents with the public key
  • This ability for a receiver to verify the JWT means that the receiver can trust the contents of the JWT, without the need to make an additional API call for that information.
    A JWKS (JSON Web Key Set) is a representation of the cryptographic key pairs mentioned above. The JWKS contains the public keys that can be used to verify a JWT. The JWT would contain a claim(kid) that specifies which key was used to sign it. The issuer of the JWT provides the JWKS.
    Working with a JWKS can introduce a few problems if not dealt with correctly. To discuss these problems, let us introduce an example use case as a starting point.
    Use Case
    Verification: How our backend authenticates a user
    When the backend receives an API call it will:
  • confirm that a JWT is present
  • verify the JWT signature using the relevant public key
  • verify other claims in the JWT (expiry, scopes, etc)
  • use the contents of the JWT to identify the user and process the rest of the request
  • Auth0 provides a JWKS endpoint, at https://TENANT_DOMAIN/.well-known/jwks.json, which we will use in step #2 to fetch the public keys to verify the received.
    In a Ruby application, steps 2 & 3 could look like this:
    module Auth
      module VerifyJwt
        extend self
    
        JWKS_URL = "https://#{Rails.configuration.auth0[:auth_domain]}/.well-known/jwks.json".freeze
    
        def call(token, audience: :web)
          JWT.decode(
            token,
            nil,
            true, # Verify the signature of this token
            algorithms: ["RS256"],
            iss: "https://#{Rails.configuration.auth0[:auth_domain]}/",
            verify_iss: true,
            aud: Rails.configuration.auth0[:web_audience],
            verify_aud: true,
            jwks: fetch_jwks,
          )
        end
    
        private
    
        def fetch_jwks
          response = HTTP.get(JWKS_URL)
          if response.code == 200
            JSON.parse(response.body.to_s)
          end
        end
      end
    end
    With the above, we have a working solution. But we have also introduced a few potentials problems that are worth resolving before deploying the application to production.
    The Problems
    Problem 1: External API call on every request
    For every authenticated request that comes into our application, we will also be making an API call to the /jwks.json endpoint. This introduces a few concerns:
  • there is additional overhead on every request, increasing the response time
  • a spike in traffic, or an attack, can result in rate limits being crossed and all our authentication processes being unavailable for a period of time
  • Solution: Caching
    External calls like the /jwks.json endpoint are a perfect opportunity for caching. The key pair used for signing our JWTs won't change unless they are rotated.
    In code, we would:
  • cache the response of the API call
  • return the cached value, if it exists, for future calls
  • module Auth
      module VerifyJwt
        extend self
        JWKS_CACHE_KEY = "auth/jwks-json".freeze
        ...
    
        def call(token)
          JWT.decode(
            ...
            jwks: jwks,
          )
        end
    
        private
    
        def fetch_jwks
          ...
        end
    
        def jwks
          Rails.cache.fetch(JWKS_CACHE_KEY) do
            fetch_jwks
          end.deep_symbolize_keys
        end
      end
    end
    Problem 2: Delayed Cache invalidation for rotated keys
    The solution to Problem 1 introduces a new issue.
    What happens when our key pairs are rotated? Leaked private keys, routine security processes, or other circumstances, are possible reasons for key rotation. Rotating key pairs means that all new JWTs will be signed by those new keys.
    After a key rotation, our application will always fail to verify new JWTS because our cache would still be returning the previous JWKS.
    Solution: Cache Invalidation
    We can solve this issue by fetching from the /jwks.json endpoint again if we recognise a JWT that was signed by a different key than those we know about. We can identify this by:
  • extracting the kid from the JWT header
  • looking for a key, in the keys of the JWKS, with the matching kid
  • fetching the JWKS again (not from cache), if no key is found
  • Fortunately, the ruby JWT gem has done the heavy lifting. When we pass through a function, instead of the raw JWKS, the library will call that function with options if it expects us to try to return a new set of JWKS. (gem implementation)
    module Auth
      module VerifyJwt
        extend self
            ...
    
        def call(token)
          JWT.decode(
            ...
            jwks: jwk_loader,
          )
        end
    
        private
    
        def jwk_loader
          ->(options) do
            # options[:invalidate] will be `true` if a matching `kid` was not found
            # https://github.com/jwt/ruby-jwt/blob/master/lib/jwt/jwk/key_finder.rb#L31
            jwks(force: options[:invalidate])
          end
        end
    
        def fetch_jwks
          ...
        end
    
        def jwks(force: false)
          Rails.cache.fetch(JWKS_CACHE_KEY, force: force) do
            fetch_jwks
          end.deep_symbolize_keys
        end
      end
    end
    Problem 3: Attacker-triggered cache invalidation
    The current solution to Problem 2 reintroduces Problem 1. An attacker could flood our API with calls using a random invalid JWT that will always fail verification.
    Because the invalid JWT would have a different kid, we would invalidate our cache every time, forcing us to do an API call.
    In theory, this could be acceptable because we are fetching the latest JWKS. But if the call to the .../jwks.json endpoint fails, maybe due to rate-limiting, we won't have a value to cache and could be caching nil. This will mean that verifying any JWTs will not be possible until our application can get a successful response from the .../jwks.json endpoint again.
    Solution: Ignore nil for cache invalidation
    Our #fetch_jwks method only returns the JSON response if the request was successful, and nil otherwise. Meaning we can cater for any failed response by leaving the current value in the cache, so we always have the latest successful response we've received.
    The Rails cache store already provides a simple helper for this use case. We need to add skip_nil: true to our fetch
    module Auth
      module VerifyJwt
        extend self
            ...
    
        def jwk_loader
          ->(options) do
            jwks(force: options[:invalidate]) || {}
          end
        end
    
        def jwks(force: false)
          Rails.cache.fetch(JWKS_CACHE_KEY, force: force, skip_nil: true) do
            fetch_jwks
          end&.deep_symbolize_keys
        end
      end
    end
    We also handle the possibility of receiving nil by defaulting to an empty hash ({}), because the JWT gem expects a hash and not nil.
    Conclusion
    Looking back, we now know how to protect our application from basic performance and security concerns. We have:
  • implemented caching of the JWKS required for verifying JWTs
  • accounted for the possibility of keys being rotated
  • protected our application from an attacker disrupting functionality for all users
  • This is final code for our JWT verification implementation here:
    module Auth
    module VerifyJwt
    extend self
    JWKS_CACHE_KEY = "auth/jwks-json".freeze
    JWKS_URL = "https://#{Rails.configuration.auth0[:auth_domain]}/.well-known/jwks.json".freeze
    def call(token)
    JWT.decode(
    token,
    nil,
    true, # Verify the signature of this token
    algorithms: ["RS256"],
    iss: "https://#{Rails.configuration.auth0[:auth_domain]}/",
    verify_iss: true,
    aud: Rails.configuration.auth0[:web_audience],
    verify_aud: true,
    jwks: jwk_loader,
    )
    end
    private
    def jwk_loader
    ->(options) do
    jwks(force: options[:invalidate]) || {}
    end
    end
    def fetch_jwks
    response = HTTP.get(JWKS_URL)
    if response.code == 200
    JSON.parse(response.body.to_s)
    end
    end
    def jwks(force: false)
    Rails.cache.fetch(JWKS_CACHE_KEY, force: force, skip_nil: true) do
    fetch_jwks
    end&.deep_symbolize_keys
    end
    end
    end
    view raw verify_jwt.rb hosted with ❤ by GitHub
    This is not an exhaustive reference to all security-related concerns that you could encounter when working with JWTs, but I hope you find it helpful as a good starting point to get up and running.
    🚀

    30

    This website collects cookies to deliver better user experience

    Verifying JWTs with JWKS in Ruby