30
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:
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:
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: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:
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:kid
from the JWT headerkeys
of the JWKS, with the matching kid
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 fetchmodule 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:
This is final code for our JWT verification implementation here:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
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