HTTPS protects a connection and authenticates its peer according to the client’s trust policy. It does not certify the site’s intentions, correctness, or availability. A security indicator is therefore neither just an encryption label nor a complete health report. A useful check separately evaluates the TLS connection, the HTTP response, and the service’s expected behavior.
Ask several concrete questions
Start with the destination and identity. Connect to the intended endpoint while verifying the intended hostname against certificate subject alternative names and the accepted trust chain. A correct IP address or a printed certificate subject is not enough. Disabling verification to make a probe pass removes the evidence that the identity check was supposed to provide.
Then inspect time validity and the negotiated protocol. A future leaf expiry alone does not establish a usable chain, and a successful TLS negotiation shows only the selected version and suite for that client. It does not enumerate every configuration the server might accept. Test explicit acceptance and rejection policies separately after relevant configuration or library changes.
Finally, define application success. HTTP status, expected content, redirects, and relevant dependencies answer different questions. A static site can present a valid certificate while returning a missing page, stale release, or upstream error. TLS is not the whole conversation even when the application serves only files.
When it breaks
An HTTP library may return a perfectly readable response for HTTP 503 without reporting a transport error. A checker that tests only err == nil will then call an unavailable service healthy. Similarly, a generic HTTP 200 can be a different virtual host, login page, or cached error document. Validate a stable service-specific response contract.
Automatic redirects can hide which endpoint was actually checked. An initial HTTPS response may point to HTTP or an unexpected destination; a final 200 alone loses that distinction. Inspect the redirect chain under an explicit policy, and do not forward credentials to new destinations merely to complete a probe.
The termination boundary matters too. A CDN’s verified certificate authenticates the client-to-CDN hop, not every subsequent hop to the origin. Monitor the public route and the relevant upstream path independently. Record the endpoint and observation time, distinguish certificate failures from HTTP failures, and keep credentials and private request data out of diagnostic output.
Check it quickly
Save this as health_probe.go and run go run health_probe.go with Go 1.23 or newer. A local TLS 1.3 fixture provides four application responses, and a separate plaintext server stands in for a redirect destination. The client trusts only the fixture’s server certificate through httptest’s supplied configuration. No public endpoint or global trust store is changed.
package main
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"runtime"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var handled, redirectHits atomic.Int64
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectHits.Add(1)
fmt.Fprint(w, "not followed")
}))
defer target.Close()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
switch r.URL.Path {
case "/ready":
fmt.Fprint(w, "ready")
case "/wrong":
fmt.Fprint(w, "different service")
case "/down":
http.Error(w, "unavailable", http.StatusServiceUnavailable)
case "/redirect":
http.Redirect(w, r, target.URL+"/landing", http.StatusFound)
default:
http.NotFound(w, r)
}
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
server.StartTLS()
defer server.Close()
probe := func(route, name string) (int, bool, error) {
config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
config.MinVersion = tls.VersionTLS13
config.ServerName = name
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,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
response, err := client.Get(server.URL + route)
if err != nil {
return 0, false, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
must(err)
check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "verified TLS 1.3 required")
if response.StatusCode == http.StatusFound {
check(response.Header.Get("Location") == target.URL+"/landing", "wrong redirect target")
}
return response.StatusCode, response.StatusCode == http.StatusOK && string(body) == "ready", nil
}
for _, test := range []struct {
path string
status int
healthy bool
}{
{"/ready", 200, true}, {"/wrong", 200, false},
{"/down", 503, false}, {"/redirect", 302, false},
} {
status, healthy, err := probe(test.path, "")
must(err)
check(status == test.status && healthy == test.healthy, "wrong health classification")
fmt.Println(test.path, "verified TLS; HTTP", status, "healthy:", healthy)
}
_, _, err := probe("/ready", "wrong.example.test")
var nameError x509.HostnameError
check(errors.As(err, &nameError), "wrong TLS name must fail")
check(handled.Load() == 4, "wrong name must not reach HTTP")
status, healthy, err := probe("/ready", "")
must(err)
check(status == 200 && healthy && handled.Load() == 5, "trusted health probe must recover")
check(redirectHits.Load() == 0, "HTTP redirect destination must remain unvisited")
fmt.Println("wrong name rejected before HTTP; redirect not followed; healthy request succeeds again")
fmt.Println(runtime.Version())
}
All four initial requests have verified TLS, but only /ready is healthy under this fixture’s contract: HTTP 200 with the exact body ready. /wrong also returns 200, yet contains a different service response. /down returns 503. Neither is a successful health result, even though the HTTP exchange itself succeeds.
The /redirect response returns 302 with a plaintext Location. ErrUseLastResponse exposes that response without following it, and the target’s counter stays zero. This probe deliberately declines all automatic redirects; it is not a general redirect-chain auditor. A real monitor should define approved destinations and transport requirements before following a chain.
An intentionally wrong TLS name produces HostnameError before another HTTP handler entry. A subsequent trusted /ready request succeeds, leaving five application requests total. Each attempt uses a separate transport, retains ordinary certificate validation, and has a two-second client timeout. That isolation makes the negative identity test independent of an already authenticated connection.
That timeout covers the entire client request, including connection setup and body reading, not just the TLS handshake. It remains active after response headers arrive. This fixture does not inject a timeout; setting a deadline alone is not a timeout-behavior test.
All assertions passed on macOS with Go 1.27.0. This distinguishes transport identity, protocol success, and application health using actual local connections. It does not enumerate public cipher suites, inspect every edge, validate revocation or browser policy, check upstream hops, or run a production workload. The exact ready token is a test contract, not a universal definition of healthy service.
Further reading
Sources
- RFC 9110: HTTP semantics, response status and redirection semantics, checked 2026-09-09.
- Go net/http, non-2xx responses, redirect control, and client timeouts, checked 2026-09-09.
- Go crypto/tls, peer validation and negotiated connection state, checked 2026-09-09.
- Go crypto/x509, hostname verification and certificate-chain checks, checked 2026-09-09.