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-44 | 8,026 | 11,074 |
ML-DSA-65 | 5,972 | 9,339 |
ML-DSA-87 | 4,911 | 6,471 |
EdDSA+ML-DSA-65 | 4,695 | 3,924 |
Correctness
- NIST ACVP known-answer tests. CI runs the full sigVer KAT subset shipped with liboqs against
Key#verifyfor 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.