Requirements
- Ruby ≥ 3.2
- CMake ≥ 3.15 and a C compiler (gcc or clang) — only needed at install time.
Install the gem
gem "jwt-pq", "~> 0.5"
# Optional — only if you want the hybrid algorithms
gem "jwt-eddsa"
By default the gem builds the bundled liboqs. To link against a
system-installed liboqs instead, pass --use-system-libraries
at install time (persisted for Bundler):
bundle config build.jwt-pq --use-system-libraries
bundle install
At runtime, if liboqs is not on the default linker path, point the gem
at the shared library with OQS_LIB
before loading jwt/pq:
# macOS
export OQS_LIB=/opt/homebrew/lib/liboqs.dylib
# Linux
export OQS_LIB=/usr/local/lib/liboqs.so
Sign and verify
require "jwt/pq"
key = JWT::PQ::Key.generate(:ml_dsa_65)
token = JWT.encode({ sub: "u-1", iat: Time.now.to_i }, key, "ML-DSA-65")
payload, header = JWT.decode(
token, key, true, algorithms: ["ML-DSA-65"]
)
header["alg"] # => "ML-DSA-65"
payload["sub"] # => "u-1"
Public-key-only verification works the same way — just construct the key from its bytes:
public_key_bytes = key.public_key # raw bytes
pub = JWT::PQ::Key.from_public_key("ML-DSA-65", public_key_bytes)
JWT.decode(token, pub, true, algorithms: ["ML-DSA-65"])
Export and distribute the public key
# JWK
jwk = JWT::PQ::JWK.new(key).export
# => { kty: "AKP", alg: "ML-DSA-65", pub: "...", kid: "..." }
# PEM
spki = key.to_pem # SPKI (public)
pkcs8 = key.private_to_pem # PKCS#8 (private) — keep this offline
# Reimport
pub_key = JWT::PQ::Key.from_pem(spki)
full_key = JWT::PQ::Key.from_pem_pair(public_pem: spki, private_pem: pkcs8)
Serve a JWKS
For multi-key rotation or cross-service verification, publish a JWK Set.
The set is indexed by RFC 7638 thumbprint, which is also the default
kid:
jwks = JWT::PQ::JWKSet.new([key_current, key_next])
File.write("public/.well-known/jwks.json", jwks.to_json)
Consume a remote JWKS
jwks = JWT::PQ::JWKSet.fetch("https://issuer.example/.well-known/jwks.json")
_payload, header = JWT.decode(token, nil, false) # peek, unverified
key = jwks[header["kid"]] or raise "unknown kid"
payload, = JWT.decode(token, key, true, algorithms: [header["alg"]])
JWKSet.fetch ships with defence-in-depth defaults: HTTPS
only, redirects rejected, 1 MB body cap, 5 s timeouts, TTL
cache with ETag revalidation. Tune via keyword args
(cache_ttl:, timeout:,
max_body_bytes:, …).
Try it live
The debugger on this site runs the same gem in the same Rails process. Click Load ML-DSA-65, then Verify signature. The token is decoded server-side — there is no JavaScript reimplementation.
Next
- Algorithms — ML-DSA-44 / 65 / 87: sizes, NIST levels, which to pick.
- Hybrid — when EdDSA + ML-DSA beats ML-DSA alone.
- Security — what the gem guarantees and what it deliberately leaves to your app.