23
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.
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.
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.
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
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 matchingkid
- 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
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.
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