Skip to main content Skip to sidebar

Post-Quantum HTTPS Server in Go

Quantum computers threaten to break the key exchange algorithms that protect today’s HTTPS traffic. Go 1.24+ ships with built-in hybrid post-quantum key exchange, so upgrading your server requires minimal code changes.

This post covers what post-quantum TLS means, how Go implements it, and how to build a server that negotiates post-quantum key exchange with compatible clients.

What is Post-Quantum Key Exchange

Current TLS 1.3 connections typically use X25519 or ECDHE for key agreement. A sufficiently powerful quantum computer could break these algorithms using Shor’s algorithm, retroactively decrypting recorded traffic (“harvest now, decrypt later” attacks).

Post-quantum key exchange algorithms like ML-KEM (Module-Lattice-based Key-Encapsulation Mechanism, standardized as FIPS 203) are designed to resist both classical and quantum attacks.

Go uses hybrid key exchange: it combines a classical algorithm (X25519 or ECDH) with ML-KEM. The connection is secure as long as either algorithm remains unbroken.

Go’s Post-Quantum TLS Support

Go added post-quantum key exchange to crypto/tls starting with Go 1.23:

Go VersionAlgorithmStatus
1.23X25519Kyber768Draft00Experimental, enabled by default
1.24X25519MLKEM768Standardized, replaced Kyber draft
1.24crypto/mlkem packageStandalone ML-KEM-768 and ML-KEM-1024

Starting with Go 1.24, X25519MLKEM768 is enabled by default when Config.CurvePreferences is nil. A plain http.ListenAndServeTLS() call already negotiates post-quantum key exchange with compatible clients (Chrome, Firefox, other Go programs).

Architecture

sequenceDiagram
    participant Client
    participant Server

    Note over Client: Generate X25519 keypair<br/>Generate ML-KEM-768 encapsulation key

    Client->>Server: ClientHello<br/>key_share: X25519 pubkey (32B) + ML-KEM encap key (1184B)

    Note over Server: X25519 key agreement (32B shared secret)<br/>ML-KEM encapsulate (32B shared secret)

    Server->>Client: ServerHello<br/>key_share: X25519 pubkey (32B) + ML-KEM ciphertext (1088B)

    Note over Client: X25519 key agreement<br/>ML-KEM decapsulate

    Note over Client,Server: Combined shared secret (64B) = ML-KEM secret || X25519 secret<br/>Fed into TLS 1.3 HKDF key schedule

    Client->>Server: Finished (encrypted)
    Server->>Client: Finished (encrypted)

    Note over Client,Server: Application data (post-quantum protected)

The hybrid approach sends both key shares in a single round trip. The ClientHello is larger than usual (1216 bytes for the key share) due to the ML-KEM encapsulation key, but the handshake completes in the same number of round trips as classical TLS 1.3.

Minimal Post-Quantum HTTPS Server

Since Go 1.24 enables X25519MLKEM768 by default, a standard HTTPS server already supports post-quantum key exchange:

package main

import (
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Post-quantum protected response"))
	})

	log.Fatal(http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil))
}

No additional configuration needed. The server will negotiate X25519MLKEM768 with clients that support it, falling back to classical X25519 for older clients.

Explicit Configuration

If you want to control the key exchange preferences:

package main

import (
	"crypto/tls"
	"log"
	"net/http"
)

func main() {
	tlsConfig := &tls.Config{
		MinVersion: tls.VersionTLS13,
		CurvePreferences: []tls.CurveID{
			tls.X25519MLKEM768,
			tls.X25519,
			tls.CurveP256,
		},
	}

	server := &http.Server{
		Addr:      ":443",
		TLSConfig: tlsConfig,
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("Post-quantum protected response"))
		}),
	}

	log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}

Note: the order of CurvePreferences controls which mechanisms are available, not their priority. Go uses an internal preference order.

Verifying PQ Key Exchange

Test with curl (built with a PQ-capable TLS library) or another Go client:

curl -v https://localhost:443 2>&1 | grep -i kem

Or write a Go client that logs the negotiated curve:

package main

import (
	"crypto/tls"
	"fmt"
	"log"
)

func main() {
	conn, err := tls.Dial("tcp", "localhost:443", &tls.Config{
		InsecureSkipVerify: true,
	})
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	state := conn.ConnectionState()
	fmt.Printf("TLS version: %x\n", state.Version)
	fmt.Printf("Key exchange: %s\n", tls.CurveID(state.CurveID).String())
}

If post-quantum key exchange was negotiated, you will see X25519MLKEM768 in the output.

Disabling Post-Quantum Key Exchange

If PQ key exchange causes compatibility issues with middleboxes or older clients, disable it via the GODEBUG environment variable:

GODEBUG=tlsmlkem=0 ./myserver

This restores the pre-Go 1.24 default (classical curves only: X25519, P-256, P-384, P-521).

Performance Considerations

The hybrid handshake adds overhead compared to classical X25519:

  • ClientHello size: ~1216 bytes key share vs ~32 bytes for X25519
  • ServerHello size: ~1120 bytes key share vs ~32 bytes for X25519
  • CPU cost: ML-KEM-768 encapsulation and decapsulation add a few microseconds per handshake

For most servers this overhead is negligible. The larger ClientHello can cause issues with buggy middleboxes that do not correctly handle large TLS records, potentially leading to handshake timeouts.

References