Disclosure: Some links on this site are affiliate links. If you purchase through them, we earn a small commission at no extra cost to you. Learn more

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

go
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

go
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:

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:

go
// 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:

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