Short answer. ML-DSA is the lattice signature scheme standardized in
FIPS 204 (August 2024), formerly CRYSTALS-Dilithium. It has three
parameter sets: ML-DSA-44, ML-DSA-65 and ML-DSA-87. Their signatures are 2420, 3309 and 4627 bytes, against 64
for Ed25519 or ECDSA P-256. Signing is hedged (randomized) by default; a deterministic mode changes only what the signer
produces. An optional context string of up to 255 bytes and a separate pre-hash variant (HashML-DSA) change what
a verifier must do, so both sides have to agree on them. OpenSSL 3.5, Go 1.27, Java 24+, .NET 10, BouncyCastle and
@noble/post-quantum all implement it. Private keys are where they disagree most. Store the 32-byte seed.
Why ML-DSA, and why now
ECDSA, EdDSA and RSA signatures can be forged by anyone with a large enough quantum computer. Unlike encrypted traffic, a signature cannot be harvested now and broken later in a way that hurts the past. A forged signature matters at the moment someone relies on it. That is why the urgent migration was key exchange, and why signatures have more time. But "more time" has limits:
- Long-lived roots of trust, such as firmware signing keys, CA roots, code-signing and document-signing keys, have to be trusted for ten or twenty years after they are created.
- NIST's draft transition plan, IR 8547, would deprecate 112-bit classical signatures after 2030 and disallow all quantum-vulnerable signatures after 2035. The deadlines and how to plan around them are covered in Post-quantum migration in 2026.
ML-DSA is the general-purpose answer from NIST. Its siblings fill niches. FIPS 205 SLH-DSA is a hash-based scheme with conservative assumptions, small keys and very large or slow signatures. Stateful hash-based LMS and XMSS (SP 800-208) are used for firmware. FN-DSA, based on Falcon, is planned as FIPS 206. NIST had not published a draft of it on csrc.nist.gov by October 2026.
The three parameter sets
| ML-DSA-44 | ML-DSA-65 | ML-DSA-87 | |
|---|---|---|---|
| NIST security category | 2 | 3 | 5 |
| Public key | 1312 B | 1952 B | 2592 B |
| Signature | 2420 B | 3309 B | 4627 B |
| Expanded private key | 2560 B | 4032 B | 4896 B |
| Private key seed | 32 B | 32 B | 32 B |
| Public key as SPKI (DER) | 1334 B | 1974 B | 2614 B |
| OID (RFC 9881) | 2.16.840.1.101.3.4.3.17 | …3.18 | …3.19 |
The raw sizes come from FIPS 204 Table 2. The SPKI sizes are what OpenSSL 3.5.7 wrote for freshly generated keys. For comparison, an Ed25519 public key is 32 bytes and its signature 64. An RSA-2048 public key is 294 bytes in SPKI and its signature 256.
Which set to pick:
- ML-DSA-65 is the usual default. JEP 497 makes it Java's default when no parameter set is given, and most guidance treats category 3 as the comfortable middle.
- ML-DSA-87 is what NSA's CNSA 2.0 requires for US national security systems ("Level V parameters" for all classification levels).
- ML-DSA-44 is the smallest. It fits where every byte counts and the data is short-lived. Go's documentation even says "Most applications should use MLDSA44."
In practice the bytes matter more than the speed. On the arm64 Linux VM used for this article, a 2-second
single-core openssl speed run (OpenSSL 3.5.7) gave these rates:
| Algorithm | Sign/s | Verify/s |
|---|---|---|
| ML-DSA-65 | 1,488 | 8,019 |
| RSA-2048 | 1,825 | 74,119 |
| ECDSA P-256 | 65,465 | 24,446 |
ML-DSA-65 signs at about the speed of RSA-2048 and verifies an order of magnitude slower than RSA, which is fast enough for most services. The size is the bigger change. A certificate chain, a JWT or a signed log entry grows by kilobytes. An ML-DSA-65 leaf certificate signed by an ML-DSA-65 CA came out at 5514 bytes in DER in our test.
Hedged, deterministic, context: what changes a signature
Three options in FIPS 204 decide which bytes come out of Sign and what Verify must be told.
Hedged versus deterministic. Inside ML-DSA, signing derives a per-signature secret from the private key,
the message and a 32-byte value rnd. By default ("hedged") rnd is fresh randomness. In the deterministic
variant it is 32 zero bytes. FIPS 204 §3.4 explains the trade-off. The precomputed part protects against a
broken random number generator, and the fresh part makes side-channel and fault attacks harder. It adds that the
deterministic variant "should not be used on platforms where side-channel attacks are a concern". Verification
is identical for both, so the choice is local to the signer. OpenSSL shows the difference directly:
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
f5c30f1347b231f64f7adf48a4dfeb77d1c259cfc568420ad1f2238335e28616 -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
c0eada83bdaa8e278defdd974cbd849fac583f7519a075ee53ec2a3d93adc6ba -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf -
Don't write tests that compare a fresh signature with a stored one unless you sign deterministically. Hedged signatures differ on every call, by design.
Context string. Both Sign and Verify take a context string ctx of at most 255 bytes, empty by
default. ML-DSA signs 0x00 ‖ len(ctx) ‖ ctx ‖ M, not M alone, so a signature made under one context does
not verify under another. Context is a cheap domain separation tool. Two protocols that share a key cannot be
tricked into accepting each other's signatures if each uses its own context. It also breaks verification in
confusing ways when one side sets it and the other doesn't:
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt context-string:invoice-v1 -out sigctx.bin
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin -pkeyopt context-string:invoice-v1
Signature Verified Successfully
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin
...:error:030000EA:digital envelope routines:EVP_DigestVerify:provider signature failure:../crypto/evp/m_sigver.c:781:ML-DSA-65 digest_verify:
Signature Verification Failure
Protocols pin the context, so you rarely choose it yourself. ML-DSA in X.509 (RFC 9881) and in JOSE and COSE (RFC 9964, May 2026) both use the empty context. RFC 9964 says "The ctx parameter MUST be the empty string".
HashML-DSA. FIPS 204 §5.4 also defines a pre-hash variant. The message is hashed first with SHA-512, SHA-256, SHAKE128 or another approved function, and the signature covers the digest together with the hash function's OID and a different domain-separation byte (1 instead of 0). It is a separate signature scheme with its own OIDs. A HashML-DSA signature is not a valid ML-DSA signature over the same message. FIPS 204 says "the 'pure' ML-DSA version is preferred", and RFC 9881 does not allow HashML-DSA in certificates. RFC 9964 does not define it for JOSE or COSE either. Use it only when a protocol requires it.
For very large messages there is a third route, external μ. The signer computes the 64-byte message
representative μ outside the signing module and passes only that in. RFC 9881 describes it. Go 1.27
(crypto.MLDSAMu) and .NET 10 (SignMu) expose it. It produces ordinary pure ML-DSA signatures, so verifiers
need nothing special.
Private key formats: store the seed
An ML-DSA key pair is generated from a 32-byte seed ξ. The "expanded" private key of 2560–4896 bytes is computed from that seed, and so is the public key. RFC 9881 defines three PKCS#8 encodings:
seed: the 32 bytes only,[0] IMPLICIT OCTET STRING. This is the recommended form. It is 54 bytes as PKCS#8.expandedKey: the expanded key only.both: a sequence with seed and expanded key.
The RFC recommends that implementations "retain and export the seed". The expansion cannot be reversed, so a key
stored without its seed can never be converted back to the small form. Libraries took different defaults, and
that breaks imports. OpenSSL 3.5 writes both by default:
0:d=0 hl=4 l=4094 cons: SEQUENCE
4:d=1 hl=2 l= 1 prim: INTEGER :00
7:d=1 hl=2 l= 11 cons: SEQUENCE
9:d=2 hl=2 l= 9 prim: OBJECT :ML-DSA-65
20:d=1 hl=4 l=4074 prim: OCTET STRING [HEX DUMP]:30820FE60420A58A79B197C3...
Go 1.27 refuses that form and tells you what to do. It accepts only the seed:
both.pem (4098 bytes): x509: ML-DSA private keys with both seed and expanded key are not supported, use e.g. "openssl pkey -provparam ml-dsa.output_formats=seed-only" to convert to a seed-only key
seed-only.pem (54 bytes): *mldsa.PrivateKey
expanded-only.pem (4060 bytes): x509: semi-expanded ML-DSA private keys without seed are not supported
Java 25 (JEP 497), .NET 10 and Go all exported 54-byte seed-only PKCS#8 keys in our tests. Pick the seed form at the source:
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in both.pem -provparam ml-dsa.output_formats=seed-only -out key.pem
The same choice applies to HSMs and KMS products. Before you generate a long-lived key, check that you can
export or back up the seed. An expanded-only key ties you to implementations that accept expanded keys.
@noble/post-quantum is a case in point: its secretKey is the expanded encoding (4032 bytes for ML-DSA-65),
and its keygen(seed) takes the 32-byte seed. Keep the seed yourself.
To check what a key or certificate actually contains, the post-quantum certificate inspector decodes ML-DSA and SLH-DSA keys and certificates, and the ASN.1 explorer shows which of the three private key forms you have.
Code that works today
Every snippet below was run on 20 October 2026 in the container named in the paragraph before it. Each signs "hello" with
ML-DSA-65 and verifies the result. Where the library supports it, a context string is set. To try the scheme
without installing anything, the post-quantum signature lab signs and
verifies ML-DSA, SLH-DSA and Falcon in the browser.
OpenSSL 3.5 (node:24.22.0-trixie, OpenSSL 3.5.7). ML-DSA needs -rawin, because pkeyutl would
otherwise expect a precomputed digest:
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in key.pem -pubout -out pub.pem
openssl pkeyutl -sign -rawin -inkey key.pem -in msg.txt -out sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey pub.pem -in msg.txt -sigfile sig.bin
# Signature Verified Successfully
Go 1.27 (golang:1.27.3-trixie). The new crypto/mldsa package. crypto/x509 parses and marshals the
keys, and crypto/tls accepts ML-DSA certificates in TLS 1.3. The package doc warns that it is unavailable
under the FIPS 140-3 Go Cryptographic Module v1.0.0.
sk, err := mldsa.GenerateKey(mldsa.MLDSA65())
if err != nil {
log.Fatal(err)
}
opts := &mldsa.Options{Context: "invoice-v1"}
sig, err := sk.Sign(nil, []byte("hello"), opts) // 3309 bytes; the io.Reader is ignored
if err != nil {
log.Fatal(err)
}
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, opts)) // <nil>
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, nil)) // mldsa: invalid signature
sk.SignDeterministic gives the deterministic variant. sk.Bytes() returns the 32-byte seed.
Java, JDK 24 and later (eclipse-temurin:25.0.4.1_1-jdk-noble). JEP 497
added ML-DSA to the standard providers. Context strings and HashML-DSA are explicit non-goals of that JEP, so
the JDK signs with the empty context only.
KeyPair kp = KeyPairGenerator.getInstance("ML-DSA-65").generateKeyPair();
Signature signer = Signature.getInstance("ML-DSA");
signer.initSign(kp.getPrivate());
signer.update("hello".getBytes(StandardCharsets.UTF_8));
byte[] sig = signer.sign(); // 3309 bytes
// kp.getPublic().getEncoded().length == 1974, kp.getPrivate().getEncoded().length == 54
BouncyCastle 1.86 (same JDK image). It supports the context through ContextParameterSpec, and it supports
HashML-DSA under its own algorithm name. Our test confirms that a HashML-DSA signature fails pure ML-DSA
verification:
Signature signer = Signature.getInstance("ML-DSA", "BC");
signer.initSign(kp.getPrivate());
signer.setParameter(new ContextParameterSpec("invoice-v1".getBytes(StandardCharsets.UTF_8)));
signer.update(msg);
byte[] sig = signer.sign(); // verifies only with the same context
Signature pre = Signature.getInstance("HASH-ML-DSA", "BC"); // SHA-512 pre-hash
pre.initSign(kp.getPrivate());
pre.update(msg);
byte[] preSig = pre.sign(); // "ML-DSA".verify(preSig) == false
.NET 10 (mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24). System.Security.Cryptography.MLDsa uses
the platform crypto library, which on Linux means OpenSSL 3.5 or newer and on Windows means CNG with PQC
support. The same program reported MLDsa.IsSupported: False in the Ubuntu 24.04 SDK image (10.0.402-noble,
OpenSSL 3.0.13) and True on Alpine 3.24 (OpenSSL 3.5.9). Check IsSupported and your base image before you
ship. In SDK 10.0.402, key generation, signing and verification compile cleanly, but the key export methods
still fail the build with error SYSLIB5006: ... is for evaluation purposes only unless you suppress the
diagnostic.
using MLDsa key = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
byte[] data = Encoding.UTF8.GetBytes("hello");
byte[] context = Encoding.UTF8.GetBytes("invoice-v1");
byte[] signature = key.SignData(data, context); // 3309 bytes
Console.WriteLine(key.VerifyData(data, signature, context)); // True
Console.WriteLine(key.VerifyData(data, signature)); // False
#pragma warning disable SYSLIB5006 // the key export methods are still [Experimental] in .NET 10
byte[] pkcs8 = key.ExportPkcs8PrivateKey(); // 54 bytes: the seed form
JavaScript and TypeScript. Native support is arriving. Node.js 24.22 creates keys with
crypto.generateKeyPairSync("ml-dsa-65") and signs with crypto.sign(null, data, privateKey) through its bundled OpenSSL 3.5, and its WebCrypto accepts
{ name: "ML-DSA-65" } with an ExperimentalWarning. Chromium 156 (the build in Playwright 1.64.1) generated a
key and a 3309-byte signature through crypto.subtle on an HTTPS page. Check the other browser engines before
you depend on it. For code that has to run everywhere, @noble/post-quantum 0.7.0 is a pure-JS implementation
that works in Node.js and in any browser (run in node:24.22.0-trixie):
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { randomBytes } from "node:crypto";
const { secretKey, publicKey } = ml_dsa65.keygen(randomBytes(32)); // keep the 32-byte seed, not secretKey
const msg = new TextEncoder().encode("hello");
const context = new TextEncoder().encode("invoice-v1");
const sig = ml_dsa65.sign(msg, secretKey, { context });
ml_dsa65.verify(sig, msg, publicKey, { context }); // true
ml_dsa65.verify(sig, msg, publicKey); // false
ml_dsa65.sign(msg, secretKey, { extraEntropy: false }); // deterministic variant
ML-DSA certificates and TLS
OpenSSL 3.5 can issue ML-DSA certificates and use them in TLS 1.3 today. This test creates an ML-DSA-65 root and
leaf and connects with s_client. A client that offers only classical signature algorithms is rejected:
leaf certificate: 5514 bytes DER
Protocol version: TLSv1.3
Signature type: mldsa65
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
--- a classic-only client
error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure
The TLS code points (mldsa44 0x0904, mldsa65 0x0905, mldsa87 0x0906) come from
draft-ietf-tls-mldsa. In October 2026 it was in the
RFC Editor queue. Go 1.27 supports the same three schemes.
For the public web this is not yet usable. Browsers do not accept ML-DSA certificates from public CAs. Firefox
keeps them behind the security.tls.enable_mldsa pref, which defaults to false. Chrome
stated in February 2026 that
it has no immediate plan to add X.509 certificates with post-quantum keys to its root store. It is working on
Merkle Tree Certificates instead, which avoid sending several kilobyte-sized signatures in every handshake. ML-DSA
certificates are realistic today in private PKI: service meshes, device identity, internal mTLS, code and
document signing where you control both ends.
Common mistakes
- Expecting a stable signature. Hedged signing is the default in every library above (we checked OpenSSL, Java and noble by signing twice), so the same input gives different bytes each time. Compare verification results, not signature bytes.
- Mixing up pure ML-DSA and HashML-DSA. They are different algorithms with different OIDs. A verifier configured for one rejects the other, and FIPS 204 recommends using a key pair for only one of them.
- Setting a context on one side only. The failure looks exactly like a bad signature. Log the context your protocol uses next to the algorithm name.
- Throwing away the seed. Exporting only the expanded key works until you need to load it into Go or
another seed-only implementation. Convert with
-provparam ml-dsa.output_formats=seed-onlywhile you still can. - Assuming the runtime has it. .NET and other wrappers around the system library inherit its version. An application that works on a developer laptop with OpenSSL 3.5 can fail in a container built on an older base image.
- Forgetting size limits. JWT headers, cookies, DNS records, X.509 extensions, database columns, QR codes, HSM buffers: anything sized for a 64-byte ECDSA signature has to fit 3309 bytes or more.
Sources
- FIPS 204, Module-Lattice-Based Digital Signature Standard (August 2024): parameter sets (Table 1), sizes (Table 2), hedged and deterministic signing (§3.4), context and HashML-DSA (§5.2–5.4).
- RFC 9881: ML-DSA algorithm identifiers for X.509, private key formats, external μ.
- RFC 9964: ML-DSA for JOSE and COSE (
ML-DSA-44/65/87, key typeAKP). - draft-ietf-tls-mldsa: ML-DSA in TLS 1.3.
- NIST IR 8547 (initial public draft): transition timeline.
- Library documentation: OpenSSL 3.5 NEWS, Go 1.27 release notes, JEP 497, What's new in .NET 10 libraries.
Command output was produced on 20 October 2026 in node:24.22.0-trixie (OpenSSL 3.5.7, Node.js 24.22.0),
golang:1.27.3-trixie, eclipse-temurin:25.0.4.1_1-jdk-noble (with bcprov-jdk18on 1.86),
mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24, mcr.microsoft.com/dotnet/sdk:10.0.402-noble and
mcr.microsoft.com/playwright:v1.64.1-noble (Chromium 156).