Implementing Hybrid TLS in Go: A Developer's Guide
TL;DR
crypto/tls gained hybrid post-quantum key exchange in two steps. Go 1.23 shipped the pre-standard
X25519Kyber768Draft00 group, enabled by default and disabled via GODEBUG=tlskyber=0. Go 1.24 replaced it
outright with the finalized ML-KEM-768-based
X25519MLKEM768 group — a different, incompatible group ID, not a rename — enabled by default and disabled
via GODEBUG=tlsmlkem=0. Both are exposed as tls.CurveID values usable in Config.CurvePreferences. The
practical work for a Go developer is not implementing the cryptography — the standard library does that — it
is deciding the ordered group-preference list: which hybrid and classical groups to advertise, in what order,
and what happens when a peer supports neither the current hybrid group nor any group you're willing to accept.
Go's Adoption Timeline
Go 1.23 (August 2024) added experimental support for X25519Kyber768Draft00, the pre-standardization hybrid
group that Chrome and several CDNs had already deployed. It was enabled by default for TLS 1.3 clients, with
GODEBUG=tlskyber=0 available to opt out.
Go 1.24 (February 2025) replaced it entirely with X25519MLKEM768, matching the finalized
FIPS 203 standard, enabled by default for both
clients and servers, with GODEBUG=tlsmlkem=0 to disable it. The draft support was removed, not kept
alongside the new group — X25519Kyber768Draft00 and X25519MLKEM768 are distinct TLS group IDs with
different wire encodings, since NIST's finalization of ML-KEM changed encoding details from the draft Kyber
submission. A Go 1.24+ server cannot negotiate hybrid key exchange with a client that only speaks the 2023–2024
draft codepoint; that requires either pinning an older Go version or a different TLS stack that still carries
the draft group for legacy interoperability.
Configuring a Hybrid-Ready Server
package main
import (
"crypto/tls"
"log"
"net/http"
)
// newServerTLSConfig returns a TLS 1.3-only config that prefers hybrid
// post-quantum key exchange but still accepts classical-only clients.
func newServerTLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
// Order is the server's preference, not just a support list: Go
// walks this slice and negotiates the first entry the client also
// offered a key_share for. Listing the hybrid group first means it
// wins whenever the client supports it.
CurvePreferences: []tls.CurveID{
tls.X25519MLKEM768, // hybrid: X25519 + ML-KEM-768
tls.X25519, // classical fallback for older clients
},
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello over hybrid TLS 1.3"))
})
server := &http.Server{
Addr: ":8443",
Handler: mux,
TLSConfig: newServerTLSConfig(),
}
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}Explicitly setting CurvePreferences here is deliberate rather than relying on the runtime default: the
default group list has already changed once between Go 1.23 and 1.24, and will change again as NIST's
selections evolve. Pinning the list explicitly — the same argument made for
crypto-agility at the application layer — means a Go
version upgrade doesn't silently change what your server negotiates.
Configuring a Hybrid-Ready Client
package main
import (
"crypto/tls"
"fmt"
"io"
"net/http"
)
// newClientTLSConfig mirrors the server's group preference so both sides
// agree on hybrid key exchange whenever it's available.
func newClientTLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{
tls.X25519MLKEM768,
tls.X25519,
},
}
}
func main() {
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: newClientTLSConfig(),
},
}
resp, err := client.Get("https://localhost:8443/")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}Fallback for Older Clients
CurvePreferences is a preference-ordered list, not an all-or-nothing switch. Keeping tls.X25519 after
tls.X25519MLKEM768 means a client with no post-quantum support at all still completes a standard classical
handshake — the server simply falls through to the next mutually supported entry in its list. This is the
same negotiation model Go has always used for classical curve selection; hybrid groups slot into the existing
mechanism rather than requiring a new one.
Two fallback scenarios need distinguishing:
- Fully classical clients (no PQC support): fall back to
tls.X25519automatically as long as it's in the preference list. No special handling required. - Draft-only clients (2023–2024-era deployments speaking only
X25519Kyber768Draft00): a Go 1.24+ stack cannot bridge this gap, since the draft group was removed rather than kept alongside the standardized one. These clients fall back to whatever classical group they and the server both support — functionally equivalent to having no hybrid support at all against a modern Go server.
For services that must reject non-hybrid connections outright — an internal service handling data with a long confidentiality requirement, per the Harvest Now, Decrypt Later threat model — omit the classical fallback entirely:
// pqcOnlyTLSConfig refuses any handshake that can't negotiate the hybrid
// group — appropriate only for services where both endpoints are known to
// support X25519MLKEM768, since this has no fallback path.
func pqcOnlyTLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{tls.X25519MLKEM768},
}
}A client or server configured this way fails the handshake outright against any peer that doesn't offer
X25519MLKEM768 — correct for a closed, version-controlled internal link, and wrong for anything
public-facing where you don't control the client population.
Verifying What Was Actually Negotiated
tls.ConnectionState doesn't expose the negotiated group directly, but a server can observe what a client
offered via GetConfigForClient, which receives a *tls.ClientHelloInfo before the handshake completes:
config := &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{
tls.X25519MLKEM768,
tls.X25519,
},
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
log.Printf("client %s offered groups: %v", hello.Conn.RemoteAddr(), hello.SupportedCurves)
return nil, nil // nil return keeps the default Config in effect
},
}This is useful during rollout to confirm, from real traffic, what fraction of connecting clients actually
offer X25519MLKEM768 before deciding whether a fallback-free config is viable for a given deployment.