Server Name Indication gives a TLS server a hostname while it is choosing handshake parameters and a certificate. In the certificate-based, no-early-data path discussed here, this happens before ordinary HTTP bytes, not before any bytes cross the network. SNI is a selection hint; the client must still verify the selected identity.
One address, separate decisions
The client places server_name in ClientHello. A server hosting multiple names can use it to choose a certificate or security configuration. TLS 1.3 encrypts its certificate message after ServerHello, so describing the whole selection and certificate exchange as happening before encryption is inaccurate.
A DNS hostname in SNI is not the destination IP, and supplying it does not require the test name to resolve through DNS. RFC 6066 excludes literal IP addresses from its HostName field and treats DNS names case-insensitively. Clients connecting by IP may omit SNI and verify an IP identity instead.
Multiple sites do not necessarily need separate certificates. One certificate can cover multiple subject alternative names, and a server without SNI can choose a default certificate. Whether the client accepts that certificate depends on its expected name and trust policy. SNI enables selection; it does not create trust or prove ownership of the requested hostname.
When it breaks
A client can reach the correct IP yet receive a default certificate for another name. Disabling hostname verification would hide that routing error. Check the connection address, intended TLS name, selected certificate’s SANs, trust chain, and expiry independently.
HTTP Host or an HTTP/2 authority belongs to the application layer. Changing that value after selecting the TLS name does not redo the handshake or replace its certificate. A valid certificate also does not authorize access to every virtual host behind an edge. Certificate selection and tenant routing need their own policies.
Unknown SNI may lead to handshake rejection or a default certificate, depending on server policy; absence of SNI does not universally mean failure. Without ECH, the ordinary ClientHello exposes SNI on the network. TLS 1.3 alone does not hide it. ECH requires additional deployment support and does not conceal every traffic characteristic.
Check it quickly
Save this as sni_probe.go and run go run sni_probe.go with Go 1.23 or newer. It generates a short-lived test CA and two leaf certificates entirely in memory. Only a local client trusts that CA. All requests connect to one loopback IP; ServerName supplies the intended TLS name without requiring public DNS. Sessions and HTTP connections are not reused.
The CA and leaf certificates expire after one hour. Their limited lifetime is a fixture choice, not a production renewal strategy. Creating a certificate does not make other clients trust it; the explicit root pool is what establishes trust for these requests.
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func leaf(name string, serial int64, ca *x509.Certificate, signer ed25519.PrivateKey) tls.Certificate {
public, private, err := ed25519.GenerateKey(rand.Reader)
must(err)
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(serial), DNSNames: []string{name},
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, ca, public, signer)
must(err)
certificate, err := x509.ParseCertificate(der)
must(err)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: private, Leaf: certificate}
}
func main() {
public, private, err := ed25519.GenerateKey(rand.Reader)
must(err)
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(1), IsCA: true, BasicConstraintsValid: true,
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
must(err)
ca, err := x509.ParseCertificate(der)
must(err)
roots := x509.NewCertPool()
roots.AddCert(ca)
alpha := leaf("alpha.example.test", 2, ca, private)
beta := leaf("beta.example.test", 3, ca, private)
certificates := map[string]*tls.Certificate{
"alpha.example.test": &alpha, "beta.example.test": &beta,
}
var handled, rejected atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
fmt.Fprintf(w, "%s|%s", r.TLS.ServerName, r.Host)
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.TLS = &tls.Config{
MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{alpha}, SessionTicketsDisabled: true,
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
certificate, ok := certificates[strings.ToLower(hello.ServerName)]
if !ok {
rejected.Add(1)
return nil, fmt.Errorf("unknown fixture SNI")
}
return certificate, nil
},
}
server.StartTLS()
defer server.Close()
request := func(name, host string) (string, error) {
config := &tls.Config{RootCAs: roots, ServerName: name, MinVersion: tls.VersionTLS13}
transport := &http.Transport{TLSClientConfig: config, DisableKeepAlives: true}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
request, err := http.NewRequest(http.MethodGet, server.URL, nil)
must(err)
request.Host = host
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}
check(response.StatusCode == 200, "unexpected HTTP status")
check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "verified TLS 1.3 required")
check(string(body) == name+"|"+host, "handler must observe separate SNI and Host")
certificate := response.TLS.PeerCertificates[0]
must(certificate.VerifyHostname(name))
return certificate.DNSNames[0], nil
}
for _, name := range []string{"alpha.example.test", "beta.example.test"} {
selected, err := request(name, name)
must(err)
check(selected == name, "SNI must select its matching certificate")
fmt.Println("SNI", name, "selected", selected)
}
selected, err := request("ALPHA.EXAMPLE.TEST", "alpha.example.test")
must(err)
check(selected == "alpha.example.test", "DNS name selection must be case-insensitive")
selected, err = request("alpha.example.test", "beta.example.test")
must(err)
check(selected == "alpha.example.test", "HTTP Host must not change the TLS certificate")
_, err = request("missing.example.test", "missing.example.test")
check(err != nil && rejected.Load() == 1, "unknown SNI must be rejected by fixture policy")
_, err = request("", "alpha.example.test")
var nameError x509.HostnameError
check(errors.As(err, &nameError), "without DNS SNI, default certificate must fail IP identity")
check(handled.Load() == 4, "only four verified requests may reach the handler")
fmt.Println("uppercase name selects alpha; Host override keeps alpha; unknown SNI and IP-name mismatch rejected")
fmt.Println(runtime.Version())
}
Alpha and beta select their respective certificates, and an uppercase alpha name still selects alpha. The client performs normal chain and hostname checks; the example additionally inspects the selected certificate. Four verified requests reach the echo handler, including the deliberate request with alpha SNI but beta Host. That request still authenticates alpha and demonstrates independent fields, not permission to serve another tenant.
An unknown SNI is rejected by this fixture’s exact-name policy. With ServerName empty, the IP URL leads to no DNS SNI; the configured default alpha certificate cannot authenticate that IP, so hostname verification fails even though Host says alpha. Neither negative case reaches HTTP. The echo handler intentionally exposes the two fields for inspection and is not a production virtual-host router.
All assertions passed on macOS with Go 1.27.0. The case normalization follows DNS-name semantics; the fixture only serves its two ASCII test names. Private keys and CA state stay in memory and are never printed or installed system-wide. The experiment does not test ECH, wildcard or internationalized names, HTTP/2 connection coalescing, session resumption, or a real CDN. Validate the intended edge’s certificate and routing behavior separately before deployment.
Further reading
Sources
- RFC 6066: TLS Extensions, server_name, hostname syntax, and server selection, checked 2026-09-09.
- RFC 8446: TLS 1.3, encrypted handshake messages after ServerHello, checked 2026-09-09.
- Go crypto/tls, ServerName, GetCertificate, and explicit ECH configuration, checked 2026-09-09.
- Go crypto/x509, test certificate creation and SAN-based hostname verification, checked 2026-09-09.
- Go net/http/httptest, temporary loopback TLS servers, checked 2026-09-09.