A TLS version policy defines which handshakes an endpoint accepts. Prefer an explicit minimum over assuming that a library’s changing defaults match your requirements. A maximum is a separate constraint: freezing it unnecessarily can prevent adoption of a newer supported protocol. Neither setting replaces certificate verification, suitable algorithms, or timely security updates.
A floor is not a ceiling
RFC 8996 deprecates TLS 1.0 and 1.1. RFC 9325 also rejects SSLv2 and SSLv3, recommends supporting TLS 1.3 and preferring it when implemented, and gives security requirements for retained TLS 1.2 support. A service with a known client population may choose TLS 1.3 only after evaluating interoperability; the existence of a stricter option does not make every TLS 1.2 connection insecure.
When both endpoints support TLS 1.3, merely allowing TLS 1.2 does not mean an attacker can freely force the weakest version. TLS includes authenticated negotiation and specific downgrade protections. However, a real old-only client can legitimately select an allowed older version, and unsafe retry behavior can undermine policy. Enforce the floor on each new connection instead of accepting success at any version.
Version numbers in a capture also need context. TLS 1.3 uses a legacy hello version value of 0x0303 for compatibility and negotiates its actual version through supported_versions. That legacy value alone is not evidence that the connection used TLS 1.2. Prefer the library’s negotiated ConnectionState when available.
When it breaks
A browser succeeding proves only one accepted path. It does not prove that TLS 1.0 is rejected, that a required TLS 1.2 client still works, or that all edge listeners enforce the same floor. Test accepted and rejected boundaries independently, including the correct hostname and trust roots.
A load balancer can terminate TLS before the request reaches the configured application. Client-to-edge and edge-to-origin policies are separate, as are another port, address family, or listener. Test the endpoint customers actually reach. Reloaded configuration is evidence of intent; observed negotiation is evidence of behavior.
Go’s TLS Config must not be modified after being passed to a TLS function. This example constructs configurations before starting servers. Apply a deployment’s supported reload procedure and test new connections; do not mutate a shared configuration while handshakes are using it.
Version overlap is necessary but insufficient. Unsupported signature algorithms, key-exchange groups, certificate names, or trust chains can still cause failure. Preserve the actual error, and do not label every handshake rejection a version mismatch. Avoid silently weakening the floor after a timeout; that can convert a transient network failure into a lasting security change.
Check it quickly
Save this as version_probe.go and run go run version_probe.go with Go 1.23 or newer. Two temporary loopback HTTPS servers share the same test logic but enforce different minimums. The fixture’s certificate is trusted only by its local test client. Requests use fresh transports, keep certificate verification enabled, and have two-second limits.
| Server range | Client range | Expected result |
|---|---|---|
| 1.2–1.3 | 1.2–1.3 | TLS 1.3 |
| 1.2–1.3 | 1.2 only | TLS 1.2 |
| 1.2–1.3 | 1.1 only | Reject |
| 1.3 only | 1.2 only | Reject |
| 1.3 only | 1.2–1.3 | TLS 1.3 |
package main
import (
"crypto/tls"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"runtime"
"sync/atomic"
"time"
)
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func probe(server *httptest.Server, minimum, maximum uint16) (uint16, error) {
config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
config.MinVersion, config.MaxVersion = minimum, maximum
check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
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 0, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return 0, err
}
check(response.StatusCode == 200 && string(body) == "ok\n", "unexpected HTTP reply")
check(response.TLS.HandshakeComplete && len(response.TLS.VerifiedChains) > 0, "verified handshake required")
return response.TLS.Version, nil
}
func main() {
var handled atomic.Int64
start := func(minimum uint16) *httptest.Server {
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: minimum, MaxVersion: tls.VersionTLS13}
server.StartTLS()
return server
}
compatible := start(tls.VersionTLS12)
defer compatible.Close()
strict := start(tls.VersionTLS13)
defer strict.Close()
version, err := probe(compatible, tls.VersionTLS12, tls.VersionTLS13)
check(err == nil && version == tls.VersionTLS13, "modern peers must select TLS 1.3")
fmt.Println("server 1.2-1.3, client 1.2-1.3:", tls.VersionName(version))
version, err = probe(compatible, tls.VersionTLS12, tls.VersionTLS12)
check(err == nil && version == tls.VersionTLS12, "TLS 1.2-only client must remain compatible")
fmt.Println("server 1.2-1.3, client 1.2 only:", tls.VersionName(version))
_, err = probe(compatible, tls.VersionTLS11, tls.VersionTLS11)
check(err != nil, "client below TLS 1.2 floor must fail")
_, err = probe(strict, tls.VersionTLS12, tls.VersionTLS12)
check(err != nil, "client below TLS 1.3 floor must fail")
check(handled.Load() == 2, "rejected handshakes must not reach HTTP")
version, err = probe(strict, tls.VersionTLS12, tls.VersionTLS13)
check(err == nil && version == tls.VersionTLS13, "strict server must still accept TLS 1.3")
check(handled.Load() == 3, "exactly three HTTP requests must succeed")
fmt.Println("both below-floor clients rejected; strict server still accepts TLS 1.3")
fmt.Println(runtime.Version())
}
The program asserts this matrix and checks that rejected handshakes never reach HTTP. The shared handler count remains two after both negative cases, then becomes three when the strict server accepts a modern client. All assertions passed on macOS with Go 1.27.0. Explicit upper bounds make this experiment reproducible; they are not a recommendation to copy a fixed ceiling into every deployment.
These are genuine negotiated connections, not configuration-file inspection. The test does not execute SSLv2, SSLv3, TLS 1.0, active downgrade attacks, or a production proxy rollout. Before raising a live floor, inventory required clients, test the intended listener configuration in staging, and monitor handshake failures during deployment. If compatibility fails, revisit that deliberate policy decision rather than disabling identity checks.
Further reading
Sources
- RFC 8996: Deprecating TLS 1.0 and TLS 1.1, obsolete-version rejection, checked 2026-09-09.
- RFC 9325: Secure Use of TLS and DTLS, version policy, preference, and interoperability guidance, checked 2026-09-09.
- RFC 8446: TLS 1.3, supported_versions and downgrade protection, checked 2026-09-09.
- Go crypto/tls, MinVersion, MaxVersion, and negotiated state, checked 2026-09-09.
- Go net/http/httptest, local TLS fixtures and trusted test clients, checked 2026-09-09.