FIPS 204 · three parameter sets

ML-DSA, at three security levels.

FIPS 204 ships three parameter sets trading signature size and speed against security margin. All three are FIPS-approved. jwt-pq registers JOSE alg values for each.

Parameter sets

alg (JOSE) NIST level Public key Signature Notes
ML-DSA-44 Category 2 (~AES-128) 1,312 B 2,420 B Smallest, fastest.
ML-DSA-65 Category 3 (~AES-192) 1,952 B 3,309 B Recommended default.
ML-DSA-87 Category 5 (~AES-256) 2,592 B 4,627 B Highest margin; largest wire footprint.

Sizes are specification-defined and identical across implementations. Base64url-encoded JWT signatures are ~4/3 of the raw byte count — an ML-DSA-65 signature comes out at roughly 4.4 KB on the wire. Budget for that against default nginx large_client_header_buffers and CDN header limits before rollout, especially for ML-DSA-87.

Which one?

  • ML-DSA-65 — the safe default. Category 3 is the recommended starting point for new systems.
  • ML-DSA-44 — when signature size matters (constrained clients, cookie-bound tokens) and Category 2 fits the threat horizon.
  • ML-DSA-87 — when a regulator, compliance framework, or internal policy demands top-tier post-quantum strength (long-lived signed firmware, archival records).

JOSE representation

jwt-pq uses the AKP key type from draft-ietf-cose-dilithium — "AKP" stands for Algorithm Key Pair, a single-field container where the key material is the raw algorithm encoding.

{
  "kty": "AKP",
  "alg": "ML-DSA-65",
  "pub": "base64url(public_key_bytes)",
  "kid": "base64url(RFC 7638 thumbprint)"
}

Private JWKs add a priv field in the same base64url shape. The kid is the RFC 7638 thumbprint over {alg, kty, pub} — deterministic, independent of the private key, and memoized by Key#jwk_thumbprint so it is cheap to reuse.

Performance

Numbers from bench/verify_throughput.rb on a single thread, Intel Core i9-9880H @ 2.30 GHz, macOS, Ruby 3.4.6, liboqs 0.15.0. Rerun on your target hardware before capacity planning — ML-DSA is ~1-2 orders of magnitude slower than Ed25519.

alg Sign (ops/s) Verify (ops/s)
ML-DSA-448,02611,074
ML-DSA-655,9729,339
ML-DSA-874,9116,471
EdDSA+ML-DSA-654,6953,924

Correctness

  • NIST ACVP known-answer tests. CI runs the full sigVer KAT subset shipped with liboqs against Key#verify for ML-DSA-44/65/87, covering positive and negative cases, on every push.
  • Cross-language interop. A weekly workflow signs with jwt-pq and verifies against dilithium-py (and vice versa) across all three parameter sets.

FIPS 204 specifies hedged signing with internal randomness, so sigGen output is non-deterministic — that is why the CI focuses on sigVer KATs plus round-trip interop.