Post-Quantum Signatures with crypto/mldsa: What ML-DSA Actually Costs
crypto/mldsa lands in go1.27rc1 with full crypto/x509 and crypto/tls integration. I signed real documents, built a certificate chain, ran a live TLS 1.3 handshake and measured exactly what ML-DSA costs against ECDSA P-256, in bytes, not estimates.

Written and benchmarked against
go1.27rc1, cut fromrelease-branch.go1.27on 18 June 2026. The release notes carried a draft warning at the time of writing. Final release is 25 August 2026.
Hook
I signed a release manifest with a key that, on paper, no quantum computer will ever break. The signature came back at 4627 bytes. The ECDSA P-256 signature for the same document would have been about 70 bytes. That single number, a roughly 66x jump for one signature, is the whole story of crypto/mldsa in go1.27rc1: the cryptography works exactly as documented, and the cost is not subtle.
ML-DSA (Module-Lattice-Based Digital Signature Algorithm, FIPS 204) is the NIST-standardised post-quantum signature scheme, and go1.27rc1 wires it all the way through: a new crypto/mldsa package, crypto/x509 certificate support, and crypto/tls signature scheme negotiation. I spent this post's companion directory signing two realistic documents directly with crypto/mldsa, building a full three-level certificate chain through crypto/x509, and running a real TLS 1.3 handshake that negotiates ML-DSA end to end, measuring every byte along the way.
Problem
The question I wanted an honest answer to was not "does it work". The release notes and the package documentation already tell you it works. The question was: what does it cost, in bytes I can point to, at each of the three ML-DSA parameter sets, and does that cost break anything a real deployment already depends on, such as an 8 KiB reverse-proxy header buffer?
Getting that answer required three things I could not shortcut. First, tls.ConnectionState in go1.27rc1 does not expose which signature scheme a handshake actually negotiated, so proving ML-DSA was used, not merely offered, needed its own methodology. Second, ECDSA P-256's DER encoding is not a fixed size, so any comparison against it has to be given as a range, not a single number. Third, and this is the one I got wrong on the first attempt, my own assumption about where ML-DSA would break down under a header size limit turned out to be false, and the test that proved it wrong is more useful than the test I originally wrote.
Deep Dive
Everything below is measured in 05-mldsa in the companion repository, run with GOTOOLCHAIN=local go1.27rc1 test -v ./05-mldsa/.... Every figure is a t.Logf line inside a passing test, not a benchmark and not an estimate.
Signing real documents, not foo/bar
I built two signers around crypto/mldsa: one for a release manifest (the kind of document a package manager checks before trusting a download) and one for a short-lived bearer token (the kind of document an API gateway checks on every request). Both use ML-DSA context strings, mldsa.Options.Context, so a signature produced for one purpose cannot be replayed as a signature for the other, even though both come from a key that could sign either. TestManifestContextIsBound proves this directly: a manifest signature verifies under ManifestContext and fails to verify under TokenContext, an empty context, or any other string.
A certificate chain through crypto/x509
I built a three-level chain (self-signed root, issuing intermediate, server leaf) for each of ML-DSA-44, ML-DSA-65 and ML-DSA-87, plus ECDSA P-256 as the comparison arm, all through the ordinary x509.CreateCertificate and x509.ParseCertificate path. Nothing here needed a special code path: crypto/x509 in go1.27rc1 treats an *mldsa.PrivateKey as a crypto.Signer like any other. Validity windows and serial numbers are pinned constants, which is what makes the ML-DSA DER encodings byte-for-byte reproducible across runs, something TestCertificateSizeIsStableForMLDSA asserts directly.
The served chain, leaf plus intermediate, is what a server actually sends on every TLS handshake:
| Kind | Leaf (bytes) | Served chain (bytes) | vs ECDSA P-256 |
|---|---|---|---|
| ML-DSA-44 | 4079 | 8169 | 8.3x |
| ML-DSA-65 | 5608 | 11227 | 11.5x |
| ML-DSA-87 | 7566 | 15143 | 15.5x |
| ECDSA-P256 | 483-485 | 979-981 | 1x (baseline) |
The ECDSA figures are a range, not a fixed number, because DER encodes the r and s signature integers at minimal length, and whichever integer's leading bit happens to be set costs a padding byte. TestCertificateSizeIsStableForMLDSA asserts the ML-DSA rows are exactly stable across repeated runs and deliberately does not make that assertion for ECDSA. Source: TestNewChainProducesAnMLDSACertificate in certs_test.go.
One asymmetry worth noting: the PKCS#8 private key encoding is 54 bytes for all three ML-DSA parameter sets, because it carries the 32 byte seed plus a small amount of ASN.1 framing, not the expanded key. The public key is what scales: 1312 bytes for ML-DSA-44, 1952 for ML-DSA-65, 2592 for ML-DSA-87. ML-DSA-44's public key alone is already larger than an entire ECDSA P-256 certificate. Source: TestMLDSACertificateRoundTripsThroughPKCS8 in certs_test.go.
A real TLS 1.3 handshake, and how I confirmed what actually negotiated
I ran an actual TLS 1.3 handshake over loopback TCP for each key kind and measured every byte with this repository's shared internal/countconn helper, wrapping the client's raw connection so Reset() could separate handshake bytes from the one application-data round trip measured afterwards.
| Kind | Scheme negotiated | Handshake total (bytes) | vs ECDSA P-256 |
|---|---|---|---|
| ML-DSA-44 | MLDSA44 | 13519 | 3.4x |
| ML-DSA-65 | MLDSA65 | 17466 | 4.4x |
| ML-DSA-87 | MLDSA87 | 22700 | 5.7x |
| ECDSA-P256 | ECDSAWithP256AndSHA256 | 3980-3983 | 1x (baseline) |
The client's write side is identical across every row, 1549 bytes, because the ClientHello, key share and Finished message do not depend on what the server presents. Every byte of growth is in what the server sends back: the certificate chain plus the CertificateVerify signature. It is also worth saying plainly what did not change: the negotiated key exchange curve is X25519MLKEM768, the hybrid classical/post-quantum group, on every row, ML-DSA or not. That is go1.27rc1's default TLS 1.3 key-exchange preference and has nothing to do with the certificate's signature algorithm. Choosing an ML-DSA certificate did not bring post-quantum key exchange into the handshake; it was already there.
Confirming that the handshake actually negotiated ML-DSA, rather than merely offered it, needed more care than I expected. I checked the full tls.ConnectionState struct in common.go: it has Version, CipherSuite, CurveID, but nothing naming the signature scheme used for CertificateVerify. So proving it took three independent observations rather than reading one field. First, what the client offered: captured from tls.ClientHelloInfo.SignatureSchemes inside the server's GetCertificate callback. Second, what the server was constrained to answer with: tls.Certificate.SupportedSignatureAlgorithms set to a single-element slice, the one scheme the key kind implies, so crypto/tls's selectSignatureScheme had exactly one option to pick with a successful handshake. Third, that the server's private key was actually invoked to sign: a wrapping crypto.Signer counts Sign calls, and the test asserts exactly one, which rules out the certificate being presented but never cryptographically used. Given the singleton constraint and a completed handshake, the negotiated scheme has to be ServerAllowed[0]. That is a sound derivation, not a guess, and it only holds because the constraint is real and enforced by crypto/tls, which the negative control below confirms.
Bearer tokens against real header limits
| Kind | Signature (raw, bytes) | Total token (bytes) | Authorization header (bytes) |
|---|---|---|---|
| ML-DSA-44 | 2420 | 3411 | 3435 |
| ML-DSA-65 | 3309 | 4596 | 4620 |
| ML-DSA-87 | 4627 | 6354 | 6378 |
All three fit comfortably under nginx's default large_client_header_buffers 4 8k (8192 bytes) and well inside net/http's DefaultMaxHeaderBytes (1 MiB). Source: TestIssueAndVerifyToken and TestTokenSizeAgainstHeaderLimits in token_test.go.
Code Examples
Context binding is what stops a manifest signature and a token signature from the same key being interchangeable. Both manifest.go and token.go sign with a distinct mldsa.Options.Context string:
const ManifestContext = "ajitem.com/go-1-27-bench/05-mldsa/manifest/v1"
func SignManifest(sk *mldsa.PrivateKey, m Manifest) (*SignedManifest, error) {
msg, err := ManifestSigningBytes(m)
if err != nil {
return nil, err
}
sig, err := sk.Sign(nil, msg, &mldsa.Options{Context: ManifestContext})
if err != nil {
return nil, fmt.Errorf("pqsign: sign manifest: %w", err)
}
return &SignedManifest{
Manifest: m,
Algorithm: sk.PublicKey().Parameters().String(),
PublicKey: sk.PublicKey().Bytes(),
Signature: sig,
}, nil
}const TokenContext = "ajitem.com/go-1-27-bench/05-mldsa/token/v1"
func IssueToken(sk *mldsa.PrivateKey, claims Claims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("pqsign: marshal claims: %w", err)
}
sig, err := sk.Sign(nil, payload, &mldsa.Options{Context: TokenContext})
if err != nil {
return "", fmt.Errorf("pqsign: sign token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(sig), nil
}TestManifestContextIsBound is the proof that binding actually works, rather than merely that the code compiles:
if err := mldsa.Verify(sk.PublicKey(), msg, signed.Signature,
&mldsa.Options{Context: pqsign.ManifestContext}); err != nil {
t.Fatalf("verify under the manifest context: %v", err)
}
for _, ctx := range []string{"", pqsign.TokenContext, "ajitem.com/release-manifest/v2"} {
t.Run("context "+ctx, func(t *testing.T) {
err := mldsa.Verify(sk.PublicKey(), msg, signed.Signature, &mldsa.Options{Context: ctx})
if err == nil {
t.Errorf("signature verified under context %q, want failure", ctx)
}
})
}Building the certificate chain uses crypto/x509 exactly as it would for any other key type; the only ML-DSA-specific line is which key generator the chain kind selects:
func (k KeyKind) generateKey() (crypto.Signer, error) {
if params, ok := k.Parameters(); ok {
sk, err := mldsa.GenerateKey(params)
if err != nil {
return nil, fmt.Errorf("generate %s key: %w", k, err)
}
return sk, nil
}
sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate %s key: %w", k, err)
}
return sk, nil
}The handshake harness constrains the server to a single signature scheme and wraps its key to count Sign calls, which is the mechanism behind the three-observation proof described above:
type recordingSigner struct {
crypto.Signer
calls atomic.Int64
// ...
}
func (s *recordingSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
s.calls.Add(1)
sig, err := s.Signer.Sign(rand, digest, opts)
// ... records opts.HashFunc(), len(digest), len(sig)
return sig, err
}signer := &recordingSigner{Signer: chain.LeafKey}
serverCert := tls.Certificate{
Certificate: chain.ServedChainDER(),
PrivateKey: signer,
Leaf: chain.Leaf,
SupportedSignatureAlgorithms: allow, // exactly one scheme, from the caller
}
serverConfig := &tls.Config{
MinVersion: minVersion,
MaxVersion: maxVersion,
GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
offeredMu.Lock()
clientOffered = append([]tls.SignatureScheme(nil), chi.SignatureSchemes...)
offeredMu.Unlock()
return &serverCert, nil
},
}And the derivation itself, once the handshake completes:
var negotiated tls.SignatureScheme
if len(allow) == 1 {
negotiated = allow[0]
}TestHandshakeNegotiatesMLDSA then asserts all three legs of the proof together: that the client offered the scheme, that the server was constrained to exactly it, and that the key was asked to sign exactly once.
if !slices.Contains(hs.ClientOffered, tc.wantScheme) {
t.Errorf("client did not offer %v; offered %v", tc.wantScheme, hs.ClientOffered)
}
if len(hs.ServerAllowed) != 1 || hs.ServerAllowed[0] != tc.wantScheme {
t.Fatalf("server was allowed %v, want exactly [%v]", hs.ServerAllowed, tc.wantScheme)
}
if hs.NegotiatedSignatureScheme != tc.wantScheme {
t.Errorf("negotiated signature scheme = %v, want %v",
hs.NegotiatedSignatureScheme, tc.wantScheme)
}
if hs.SignerCalls != 1 {
t.Errorf("server key was asked to sign %d times, want 1", hs.SignerCalls)
}Lessons Learned
Two things in this directory went differently to what I expected going in, and both are more useful than the expectation would have been.
My own assumption that ML-DSA tokens would blow an 8 KiB nginx header buffer was wrong. I initially wrote TestTokenSizeAgainstHeaderLimits to assert the header exceeds 8192 bytes, on the reasoning that post-quantum overhead has to break something eventually. It failed: even ML-DSA-87, the largest parameter set, produces an Authorization header of 6378 bytes, comfortably under nginx's default large_client_header_buffers 4 8k. I fixed the test to assert what is actually true rather than force the original expectation. The corrected framing is more interesting than either pre-formed one: ML-DSA bearer tokens are usable behind stock reverse-proxy defaults today, with far less header budget left over than an ECDSA or Ed25519 token would leave. An ECDSA P-256 token's signature is roughly 70-72 bytes raw against ML-DSA-87's 4627.
Pinning both sides of a handshake to TLS 1.2 with an ML-DSA certificate fails with a generic error, not the specific one the source suggests. crypto/tls/handshake_client.go has an explicit client-side check, around line 1169, that returns "tls: server's certificate uses ML-DSA, which requires TLS 1.3" when a client parses a server certificate with an ML-DSA public key under TLS below 1.3. I expected TestMLDSARequiresTLS13 to surface exactly that string. It did not. isDisabledSignatureAlgorithm in common.go, around line 1794, excludes MLDSA44, MLDSA65 and MLDSA87 for any version below TLS 1.3, so when both sides are pinned to TLS 1.2, selectSignatureScheme on the server finds no allowed signature scheme before it ever sends its Certificate message, and the connection fails with an internal_error alert rather than the illegal_parameter alert the client-side check would produce. The client-side message is real, reachable code, but it is not what fires in the straightforward "pin both sides to 1.2" case. What I actually observed, and what the post reports, is:
_, err = pqsign.RunHandshakeAtVersion(chain, tls.VersionTLS12)
// err.Error() == "remote error: tls: internal error"The negative control for the singleton-constraint trick behind the handshake proof held up the same way: pinning an ML-DSA-65 key to []tls.SignatureScheme{tls.MLDSA44}, a scheme the key cannot produce, reliably fails with remote error: tls: handshake failure, confirmed for three mismatched combinations. If a mismatched scheme had quietly succeeded, the ServerAllowed[0] derivation used everywhere else in this post would have proven nothing.
Takeaways
crypto/mldsa in go1.27rc1 does exactly what its documentation says: crypto/x509 treats an ML-DSA key as an ordinary crypto.Signer, crypto/tls negotiates MLDSA44, MLDSA65 and MLDSA87 as first-class signature schemes, and every behaviour I checked against the release notes held, apart from which of two correctly documented code paths fires first under a specific negative scenario.
The cost is real and it is a multiple, not a percentage. A served certificate chain runs 8.3x to 15.5x larger than ECDSA P-256, and a TLS 1.3 handshake runs 3.4x to 5.7x larger, scaling with the parameter set. Neither number should be quoted without the parameter set attached: ML-DSA-44 and ML-DSA-87 are not close.
That cost is not evenly distributed. It is concentrated in certificate chains and handshakes, not in steady-state traffic: the 24 byte application round trip measured after the handshake in this directory cost the same regardless of key kind, because ML-DSA's overhead lives entirely in the signature and the public key, not in symmetric traffic afterwards.
And the one place I expected ML-DSA to fail outright, a bearer token squeezed through a default 8 KiB header buffer, it did not. All three parameter sets fit, ML-DSA-87 with the least room to spare. Post-quantum overhead is real and it is not evenly distributed, but it is smaller in header-sized payloads than intuition suggests. Full code, tests and the measured sizes behind every table above are in 05-mldsa in the companion repository.