Skip to main content
Post-quantum TLS in Go: on by default, unless your go.mod says otherwise
Engineering Sep 27, 2026

Post-quantum TLS in Go: on by default, unless your go.mod says otherwise

Since Go 1.24, a default tls.Config negotiates X25519MLKEM768, a hybrid of classic X25519 and the ML-KEM algorithm from FIPS 203. That protects today’s traffic against harvest now, decrypt later: recording it now and breaking it once a quantum computer exists. The hybrid is at least as strong as X25519 alone, and costs about 1.2 KB more in the ClientHello. Our post-quantum docs cover what it does and does not protect, and there is a Node.js version of this post.

Several ordinary things turn it off without an error. One of them is a line in your go.mod you probably haven’t looked at in years. We tested each one on Go 1.26 and 1.27 against sslboard.com, which supports hybrid key exchange.

What Go does by default, by version

GoDefault post-quantum key exchangeSwitch it off
1.23X25519Kyber768Draft00 (pre-standard, since removed)GODEBUG=tlskyber=0
1.24X25519MLKEM768GODEBUG=tlsmlkem=0
1.25Same, and ConnectionState.CurveID shows the resultSame
1.26Adds SecP256r1MLKEM768 and SecP384r1MLKEM1024GODEBUG=tlssecpmlkem=0 for those two

This applies to clients and servers alike.

Check what your code negotiates

On Go 1.25+, the client side reads the connection state:

conn, err := tls.Dial("tcp", "api.example.com:443", &tls.Config{})
if err != nil {
	log.Fatal(err)
}
defer conn.Close()
fmt.Println(conn.ConnectionState().CurveID) // X25519MLKEM768

On the server side, log r.TLS.CurveID in a handler for a day and you will know which of your clients are post-quantum. From outside, OpenSSL 3.5+ shows it directly:

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null | grep "Negotiated TLS1.3 group"
Negotiated TLS1.3 group: X25519MLKEM768

The go.mod trap

Go ties behavior changes to the go line in go.mod, not to the toolchain. That keeps old programs working after a compiler upgrade. It also means a project started on Go 1.23 keeps Go 1.23’s key exchange forever, with no warning. We changed only the toolchain and the go line:

Toolchaingo lineDefault configCurvePreferences lists X25519MLKEM768
Go 1.27.1go 1.27X25519MLKEM768X25519MLKEM768
Go 1.27.1go 1.23X25519X25519MLKEM768
Go 1.26go 1.24X25519MLKEM768X25519MLKEM768
Go 1.26go 1.23X25519X25519

The “Default config” column is working as designed, and it catches people. Two reports of “X25519MLKEM768 not enabled by default” (#75453, #76677) were closed as not planned. One reporter also saw 3DES and RSA key exchange enabled, which is the same old go line bringing back other old defaults.

The last row was a real bug. Up to Go 1.26, the old default also blocked the hybrid group when you asked for it by name. Go 1.27 fixed that.

Any of these fixes works, and we tested all three:

// 1. Move the go line forward. This also opts you in to every other
//    behavior change since your old version, so read the release notes.
go 1.27
// 2. Keep the go line and override just this setting in go.mod.
go 1.23

godebug tlsmlkem=1
// 3. In the main package, above the package clause.
//go:debug tlsmlkem=1

package main

Option 2 is the smallest change.

CurvePreferences replaces the defaults

Hardening guides often say to set CurvePreferences. Any list replaces Go’s defaults, so a list without a hybrid group means no post-quantum:

// X25519. No post-quantum.
&tls.Config{CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}}

// X25519MLKEM768.
&tls.Config{CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256}}

Since Go 1.24 the order of the list is ignored anyway, so the simplest fix is to delete the field.

TLS 1.2 and the other side

Hybrid key exchange only exists in TLS 1.3. MaxVersion: tls.VersionTLS12 got us X25519 from the same server.

Both ends also need support. A Go server with default settings negotiated X25519MLKEM768 with OpenSSL 3.6, but the curl that ships with macOS (LibreSSL) got plain X25519 from that same server.

If a broken middlebox chokes on the larger ClientHello, the symptom is a handshake that hangs until it times out. tldr.fail tracks these. GODEBUG=tlsmlkem=0 is the escape hatch while the other side gets fixed, so put a removal date on it.

What this does not cover

Your certificate is still RSA or ECDSA. Post-quantum certificates are a separate, later migration. And if a CDN terminates TLS, the public handshake is the CDN’s, while the hop to your Go service is a separate connection with its own answer.

Check every endpoint

One Go service is easy to check. Fifty public hostnames on different stacks, CDNs and go.mod files are not.

SSLBoard finds every hostname under your domain through Certificate Transparency logs, connects to each one, and reports whether it negotiates a post-quantum key exchange, with the group where it can be observed. Run a free scan and look at the post-quantum section. Any host on plain X25519 is a candidate for one of the fixes above.