Skip to content
ecdsa.com

Verify a JWT with JWKS · Ruby

How to verify a JWT with a JWKS in Ruby

To verify a JWT against a JWKS in Ruby, give JWT.decode from the jwt gem a jwks loader: the gem asks it for keys, picks the one named by the token's kid, and calls it again with kid_not_found when a key is missing, so the loader can refetch after a rotation. Issuer, audience and exp are checked by decode options. The run checks five tokens.

Tested with jwt (ruby-jwt) 3.3.0 (Ruby 4.0.7, openssl gem 4.0.2).

AccessTokenVerifier — jwks loader with cache and refetch, strict decode options
require "jwt" # gem install jwt -v 3.3.0
require "net/http"
require "json"
class AccessTokenVerifier
JWKS_URI = URI("https://issuer.example.com/.well-known/jwks.json")
def verify(token)
payload, _header = JWT.decode(token, nil, true,
algorithms: ["ES256"], # never take the algorithm from the token
jwks: method(:load_jwks),
iss: "https://issuer.example.com", verify_iss: true,
aud: "api://orders", verify_aud: true,
required_claims: ["exp"])
payload
end
private
# ruby-jwt calls this to look up keys by kid; when a kid is missing it calls it
# again with kid_not_found: true. Refetch then, at most once every 5 minutes.
def load_jwks(options)
@jwks = nil if options[:kid_not_found] && @fetched_at.to_i < Time.now.to_i - 300
@jwks ||= begin
@fetched_at = Time.now.to_i
keys = JWT::JWK::Set.new(JSON.parse(Net::HTTP.get(JWKS_URI)))
keys.reject! { |key| key[:use] == "enc" } # "use" is optional: drop only encryption keys
keys
end
end
end
check every token in tokens.txt
verifier = AccessTokenVerifier.new
File.foreach("tokens.txt") do |line|
label, token = line.split
begin
claims = verifier.verify(token)
puts "#{label}: accepted, sub=#{claims["sub"]}"
rescue JWT::DecodeError => e # base class of every verification error
puts "#{label}: rejected: #{e.class}: #{e.message}"
end
end

How it works

  1. jwks: accepts a JWT::JWK::Set or anything callable; a callable lets the class cache the set and decide when to refetch.
  2. On a missing kid, the gem calls the loader again with kid_not_found: true; the loader refetches only if the cache is older than 5 minutes.
  3. algorithms: ["ES256"] pins the algorithm; iss: with verify_iss: true, aud: with verify_aud: true and required_claims: define a valid token.
  4. All verification errors inherit from JWT::DecodeError, with specific classes such as JWT::InvalidAudError and JWT::ExpiredSignature.

Gotchas

  • Audience and issuer are checked only when asked: without aud: and verify_aud: true, the token for api://billing is accepted.
  • The filter in many examples, select! { |key| key[:use] == "sig" }, removes every key when the JWKS omits use (which is optional) — then every token fails. Drop only keys marked enc, as above.
  • Tokens without kid are rejected when jwks: is used, unless allow_nil_kid: true is passed.
  • Net::HTTP.get waits up to 60 seconds to connect and 60 seconds per read. Use Net::HTTP.start with short open_timeout and read_timeout values, so a slow issuer cannot stall every request that needs a refetch.

Related recipes