FIPS 204 · ML-DSA · draft-ietf-cose-dilithium

Post-quantum JWTs for Ruby.

jwt-pq adds ML-DSA (FIPS 204) and hybrid EdDSA + ML-DSA signatures to the ruby-jwt ecosystem. Drop-in with JWT.encode and JWT.decode, JWK import/export, RFC 7638 thumbprints, and a streaming-safe remote JWKS loader.

post-quantum JWT debugger

Verify against the real gem.

Paste an alg: ML-DSA-* or alg: EdDSA+ML-DSA-* token plus its public JWK. Rails hands both to JWT::PQ::JWK.import and JWT.decode, then renders whatever the gem returns. No JavaScript crypto. No mock.

Load sample
01   Encoded token 0 bytes
02   Public JWK (kty: "AKP") 0 bytes
Idle — paste a token, or load a sample.
Header · JOSE
{
  /* header will appear here after verify */
}
Payload · Claims
{
  /* payload claims will appear here */
}
thirty-second example

Drop-in with ruby-jwt.

require "jwt/pq" registers the new algorithms via ruby-jwt's public SigningAlgorithm.register_algorithm hook — not a monkey-patch. Load order between jwt and jwt/pq does not matter, and registration is idempotent.

require "jwt/pq"

key   = JWT::PQ::Key.generate(:ml_dsa_65)
token = JWT.encode({ sub: "u-1" }, key, "ML-DSA-65")

payload, header = JWT.decode(
  token, key, true, algorithms: ["ML-DSA-65"]
)
header["alg"]  # => "ML-DSA-65"
feature map
01

ML-DSA at three levels

All FIPS 204 parameter sets: ML-DSA-44, ML-DSA-65, ML-DSA-87. NIST categories 2, 3, 5.

See the sizes
02

Hybrid, if you need it

EdDSA+ML-DSA-{44,65,87} concatenates an Ed25519 signature with an ML-DSA one. Both must verify. Break one algorithm, the token still holds.

Hybrid details
03

JWK & PEM, both ways

JWK export/import (kty: "AKP") with RFC 7638 thumbprints as kid. PEM via SPKI (public) and PKCS#8 (private).

Serialize a key
04

Remote JWKS, hardened

JWT::PQ::JWKSet.fetch(url) with a TTL cache, ETag revalidation, HTTPS-only, 1 MB body cap, and redirect rejection. Failures raise JWKSFetchError.

Reference
05

KAT-verified

CI runs the full NIST ACVP sigVer known-answer tests against Key#verify for all three parameter sets, every push — plus cross-interop against dilithium-py.

Interop workflow
06

Thread-safe, zeroing keys

Per-instance mutex serializes sign/verify/destroy. Key#destroy! zeroes the secret buffer in Ruby and in FFI memory; a GC finalizer wipes buffers as a safety net.

Security model
a word on size

ML-DSA signatures are bigger. Plan for it.

An ML-DSA-65 JWT signature is roughly 4.4 KB after base64url encoding — vs ~86 B for Ed25519 or ~342 B for RS256. Cookie tokens, header buffer limits, and log pipelines all need a look before rollout.

alg level public key signature
ML-DSA-44 2 1,312 B 2,420 B
ML-DSA-65 3 1,952 B 3,309 B
ML-DSA-87 5 2,592 B 4,627 B