TLS 1.3 establishes traffic keys and authenticates the peer under the application’s trust policy. Ordinary HTTPS authenticates the server; client certificates are optional, not an automatic consequence of encryption. A successful TCP connection alone establishes neither identity nor a working HTTP service.

What one round trip means

In a normal certificate-based handshake, ClientHello offers versions, algorithms, and key shares. ServerHello selects parameters and contributes the server’s share. The server then sends encrypted extensions, its certificate, CertificateVerify, and Finished. The client checks the certificate and handshake proofs before sending its own Finished and ordinary application data.

Without HelloRetryRequest, the client can send that data after one TLS round trip over an established transport. This excludes DNS lookup, TCP establishment, and the HTTP response journey. A missing suitable initial key share can require HelloRetryRequest and another exchange; no mutually acceptable parameters can instead make negotiation fail. One RTT is a protocol path, not a promise about total page latency.

TLS 1.3 cipher-suite names identify record protection and a hash. They do not by themselves identify the certificate’s signature algorithm or the negotiated key-exchange group. Seeing AES in a cipher name does not establish which authentication checks ran.

Authentication and forward secrecy

Certificate-based ephemeral Diffie-Hellman protects recorded sessions against later compromise of the certificate’s private key, assuming ephemeral secrets were erased and endpoints were not compromised during the session. An RSA certificate can authenticate an ephemeral exchange; it does not imply obsolete RSA key transport.

However, TLS 1.3 also defines PSK-only and PSK-with-DHE modes. PSK-only lacks the latter’s forward-secrecy property. Optional early data has weaker security too: it is not forward secret and lacks ordinary cross-connection replay guarantees. Receiving a session ticket neither enables early data automatically nor turns every later handshake into zero RTT.

When it breaks

Separate version negotiation, trust-chain validation, hostname verification, and application behavior. Disjoint version ranges fail before HTTP. An untrusted issuer or wrong subject alternative name must also fail, even when an encrypted channel could otherwise be created. Expired certificates and an incorrect clock need their own checks; this experiment does not inject them.

SNI helps select a virtual host, while hostname verification checks the returned identity. Neither a displayed certificate nor a negotiated cipher proves that verification succeeded. Keep the original error and the intended name when diagnosing failures. Do not disable certificate verification, lower the protocol floor, or retry indefinitely merely to make a probe green.

The exact TLS 1.3 version range below makes negotiation observable; it is not an unreviewed production policy. Version bounds do not repair certificate trust. If a reverse proxy terminates TLS, inspect its client-facing connection separately from upstream TLS: those are independent handshakes and can negotiate different versions and authenticate different identities.

Check it quickly

Save the following as handshake_probe.go and run go run handshake_probe.go with Go 1.23 or newer. It uses only the standard library and a temporary loopback HTTPS server. The fixture’s certificate is trusted only by its test client; it is not installed into the machine’s trust store. Each request has a two-second limit and uses a new HTTP transport.

package main

import (
	"crypto/tls"
	"crypto/x509"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"runtime"
	"sync/atomic"
	"time"
)

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func fetch(server *httptest.Server, config *tls.Config) (tls.ConnectionState, error) {
	transport := &http.Transport{
		TLSClientConfig:   config,
		DisableKeepAlives: true,
	}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
	response, err := client.Get(server.URL)
	if err != nil {
		return tls.ConnectionState{}, err
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil {
		return tls.ConnectionState{}, err
	}
	check(response.StatusCode == 200 && string(body) == "ok\n", "unexpected HTTP reply")
	return *response.TLS, nil
}

func main() {
	var handled atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		fmt.Fprintln(w, "ok")
	}))
	server.Config.ErrorLog = log.New(io.Discard, "", 0)
	server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
	server.StartTLS()
	defer server.Close()

	trusted := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
	trusted.MinVersion, trusted.MaxVersion = tls.VersionTLS13, tls.VersionTLS13
	check(!trusted.InsecureSkipVerify, "certificate verification must stay enabled")
	state, err := fetch(server, trusted)
	check(err == nil, fmt.Sprint(err))
	check(state.HandshakeComplete && state.Version == tls.VersionTLS13, "TLS 1.3 required")
	check(!state.DidResume && len(state.VerifiedChains) > 0, "a verified full handshake is required")
	fmt.Println(tls.VersionName(state.Version), tls.CipherSuiteName(state.CipherSuite), "verified")

	wrongName := trusted.Clone()
	wrongName.ServerName = "wrong.invalid"
	_, err = fetch(server, wrongName)
	var nameError x509.HostnameError
	check(errors.As(err, &nameError), "wrong name must fail hostname verification")

	untrusted := trusted.Clone()
	untrusted.RootCAs = x509.NewCertPool()
	_, err = fetch(server, untrusted)
	var trustError x509.UnknownAuthorityError
	check(errors.As(err, &trustError), "empty roots must reject the test certificate")

	oldVersion := trusted.Clone()
	oldVersion.MinVersion, oldVersion.MaxVersion = tls.VersionTLS12, tls.VersionTLS12
	_, err = fetch(server, oldVersion)
	check(err != nil, "disjoint protocol versions must fail")
	check(handled.Load() == 1, "failed TLS attempts must not reach the HTTP handler")

	_, err = fetch(server, trusted)
	check(err == nil && handled.Load() == 2, "trusted TLS 1.3 must still work")
	fmt.Println("wrong name, untrusted certificate, and TLS 1.2-only client rejected before HTTP")
	fmt.Println(runtime.Version())
}

The verified request prints TLS 1.3 and a cipher name; the exact cipher can vary. The hostname and empty-root cases assert specific X.509 error types. The TLS-1.2-only client then fails against the TLS-1.3-only server. The handler count stays at one across all three rejected attempts, and a final correctly configured request raises it to two. Thus failure does not depend on an HTTP error response.

On the checked Go 1.27.0 macOS run, all assertions passed with certificate verification enabled. This demonstrates real TLS handshakes and failure boundaries, not packet-level RTT measurement, mutual TLS, early data, public DNS, or a production certificate rollout. Before changing a live endpoint, test the supported client population and its actual trust roots separately.

Further reading

Sources

  • RFC 8446: TLS 1.3, sections 1.2 and 2–2.3: full handshakes, PSK modes, retry flights, and early-data limits, checked 2026-09-09.
  • Go crypto/tls, version bounds, certificate verification, and ConnectionState, checked 2026-09-09.
  • Go net/http/httptest, loopback TLS fixtures and the test client’s trust configuration, checked 2026-09-09.