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:

  1. confirm that a JWT is present
  2. verify the JWT signature using the relevant public key
  3. verify other claims in the JWT (expiry, scopes, etc)
  4. 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:

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.

🚀

23