Skip to content
ecdsa.com
← All articles

ML-DSA for developers: FIPS 204 keys, signatures, code

The post-quantum signature you will actually deploy. What the three parameter sets cost in bytes, which options change the signature, why a key from one library will not load in another, and tested code for six stacks.

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-44ML-DSA-65ML-DSA-87
NIST security category235
Public key1312 B1952 B2592 B
Signature2420 B3309 B4627 B
Expanded private key2560 B4032 B4896 B
Private key seed32 B32 B32 B
Public key as SPKI (DER)1334 B1974 B2614 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:

AlgorithmSign/sVerify/s
ML-DSA-651,4888,019
RSA-20481,82574,119
ECDSA P-25665,46524,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 3.5.7: hedged vs deterministic ML-DSA-65
$ 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 3.5.7: signed with a context, verified without
$ 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:

openssl asn1parse: OpenSSL's default ML-DSA-65 key, 4098 bytes (hex shortened)
    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:

Go 1.27.3: x509.ParsePKCS8PrivateKey on OpenSSL output
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 3.5: generate or convert to a seed-only private key
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 3.5: keys, signature, verification
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.

Go 1.27: crypto/mldsa with a context
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.

Java 25: ML-DSA with the built-in provider
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:

BouncyCastle 1.86: context string and HashML-DSA
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.

.NET 10: MLDsa with a context
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):

@noble/post-quantum 0.7.0
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:

OpenSSL 3.5.7: TLS 1.3 with an ML-DSA-65 certificate
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-only while 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

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).