Skip to content
ecdsa.com

Verify an ES256 JWT · Ruby

How to verify an ES256 JWT in Ruby

To verify an ES256 JWT in Ruby, use the jwt gem (ruby-jwt): JWT.decode with an OpenSSL public key, verification switched on and algorithm: 'ES256' checks the signature and the claims you ask for. The gem converts the token's raw r‖s signature to DER for OpenSSL itself; what it does not do is check any claim you did not explicitly enable.

Tested with jwt (Ruby gem) 3.3.0 (Ruby 4.0.7, openssl gem 4.0.2, OpenSSL 3.5.7).

install the gem
gem install jwt -v 3.3.0 # or in a Gemfile: gem "jwt", "3.3.0"
verify.rb — ES256 pinned, issuer and required claims checked
require "jwt"
require "openssl"
public_key = OpenSSL::PKey.read(File.read("pub.pem"))
token = File.read("token.txt").strip
payload, header = JWT.decode(
token, public_key, true, # true: verify the signature and the claims
algorithm: "ES256", # the only algorithm accepted
iss: "https://ecdsa.com", verify_iss: true,
required_claims: %w[exp sub], # exp is checked only when present otherwise
leeway: 30 # seconds of clock skew for exp and nbf
)
puts "valid #{header['alg']} token for #{payload['sub']}, expires #{Time.at(payload['exp']).utc}"

How it works

  1. OpenSSL::PKey.read loads the SPKI public key; ruby-jwt accepts the resulting OpenSSL::PKey::EC directly as the verification key.
  2. The third argument true enables verification, and algorithm: "ES256" pins the algorithm — a token whose header says HS256 raises JWT::IncorrectAlgorithm.
  3. iss: together with verify_iss: true compares the issuer; required_claims makes exp and sub mandatory; leeway allows clock skew for exp and nbf.
  4. decode returns [payload, header] as plain hashes, or raises a JWT::DecodeError subclass — the run triggers VerificationError, IncorrectAlgorithm, ExpiredSignature and InvalidIssuerError.

Gotchas

  • iss: "https://ecdsa.com" on its own does nothing: without verify_iss: true a token from any issuer is accepted. The same pattern applies to aud: and verify_aud.
  • A token without exp is accepted unless exp is listed in required_claims — expiry is only enforced when the claim exists.
  • JWT.decode(token, nil, false) returns the payload without checking anything; it is for debugging only, never for authorization.
  • Keys from an identity provider rotate: pass jwks: (a JWT::JWK::Set or a loader lambda) instead of a fixed key, so the token's kid selects the current key.

Related recipes