quickstart · five minutes

From zero to a signed post-quantum JWT.

jwt-pq installs like any Ruby gem. liboqs (the C library underneath ML-DSA) is bundled — it compiles from source the first time you run bundle install (~30 seconds).

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.