HTTP Strict Transport Security tells a supporting client to use HTTPS for a remembered hostname. It can eliminate the insecure HTTP request before a redirect, but only when the client already knows the policy, through an earlier authenticated response or a supported preload mechanism. Merely adding a header cannot retroactively protect an unknown client’s first HTTP visit.

The client remembers a host

A valid Strict-Transport-Security header received over error-free HTTPS establishes or refreshes the policy. max-age is a duration in seconds measured from receipt. A header sent over plaintext HTTP cannot establish this trust, and omitting the header later does not erase an unexpired entry. Each client maintains its own state; browsers do not share one universal cache.

The scope is a DNS hostname, not an HTTP origin or an individual port. includeSubDomains extends coverage downward, not to a parent or sibling name. For an upgraded URL, explicit port 80 becomes 443, while other explicit ports remain unchanged. An HTTP-only service on an alternate port can therefore break after policy is learned.

The policy changes the client’s URL handling; it does not turn a plaintext listener into a TLS listener. The upgraded destination must already support HTTPS. The fixture below deliberately sends HSTS over HTTP as a negative test, not deployment advice. Neither server redirects: reaching the TLS handler from an HTTP URL demonstrates client-side upgrading, not a successful redirect chain.

This is an upgrade rule, not a certificate bypass. Supporting browsers must still validate HTTPS and do not offer their usual click-through escape for an HSTS certificate error. HSTS cannot fix an expired certificate, restore an unavailable TLS endpoint, or prove that the application behind an authenticated connection is trustworthy.

When it breaks

A long lifetime or includeSubDomains can reach forgotten services, delegated subdomains, and internal hostnames. Inventory those dependencies before expanding coverage. Begin with a deliberately short policy, validate real entry points, and ensure certificate renewal works. Scanner recommendations are not evidence that every covered host can honor the commitment.

Recovery requires precision. An authenticated max-age=0 response removes that host’s dynamic policy for clients receiving it; it does not clear independently stored child policies or immediately update offline clients. Removing the header alone is insufficient. Browser preload distribution and removal are separate processes, not instant consequences of toggling a response field.

Testing only header presence misses client behavior. Use an isolated state store and verify both the initial visit and later HTTP URLs. Keep such a test away from personal browser profiles, and never disable certificate verification just to obtain a reassuring upgrade result. Different clients and preload configurations need their own checks.

Check it quickly

Save this as hsts_probe.go and run go run hsts_probe.go with Go 1.23 or newer and an HSTS-enabled curl supporting –hsts. The program creates two loopback servers and a temporary P-256 CA. It writes only the public CA certificate and curl’s cache to a private temporary directory, removes that directory on exit, and keeps private keys in memory.

package main

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"log"
	"math/big"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"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 issue(template, parent *x509.Certificate, public any, signer *ecdsa.PrivateKey) *x509.Certificate {
	der, err := x509.CreateCertificate(rand.Reader, template, parent, public, signer)
	must(err)
	certificate, err := x509.ParseCertificate(der)
	must(err)
	return certificate
}

func main() {
	curl, err := exec.LookPath("curl")
	must(err)
	versionCommand := exec.Command(curl, "--disable", "--version")
	versionCommand.Env = append(os.Environ(), "SSLKEYLOGFILE=")
	version, err := versionCommand.Output()
	must(err)
	check(strings.Contains(string(version), "HSTS"), "curl must support HSTS")
	caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	must(err)
	leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	must(err)
	now := time.Now()
	template := &x509.Certificate{
		SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "HSTS fixture CA"},
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
	}
	ca := issue(template, template, caKey.Public(), caKey)
	host, child := "hsts.example.test", "child.hsts.example.test"
	leaf := issue(&x509.Certificate{
		SerialNumber: big.NewInt(2), DNSNames: []string{host, child},
		NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
		KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}, ca, leafKey.Public(), caKey)
	folder, err := os.MkdirTemp("", "hsts-probe-")
	must(err)
	defer func() { must(os.RemoveAll(folder)) }()
	caFile, cacheFile := filepath.Join(folder, "ca.pem"), filepath.Join(folder, "hsts.txt")
	must(os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw}), 0600))

	var plainCount, secureCount atomic.Int64
	plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		plainCount.Add(1)
		if r.URL.Path == "/learn" {
			w.Header().Set("Strict-Transport-Security", "max-age=3600; includeSubDomains")
		}
		fmt.Fprint(w, "plain")
	}))
	defer plain.Close()
	secure := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		secureCount.Add(1)
		check(r.TLS != nil, "secure handler requires TLS")
		switch r.URL.Path {
		case "/learn":
			w.Header().Set("Strict-Transport-Security", "max-age=3600; includeSubDomains")
		case "/clear":
			w.Header().Set("Strict-Transport-Security", "max-age=0")
		}
		fmt.Fprint(w, "secure")
	}))
	secure.Config.ErrorLog = log.New(io.Discard, "", 0)
	secure.TLS = &tls.Config{
		MinVersion:   tls.VersionTLS12,
		Certificates: []tls.Certificate{{Certificate: [][]byte{leaf.Raw}, PrivateKey: leafKey, Leaf: leaf}},
	}
	secure.StartTLS()
	defer secure.Close()
	plainURL, err := url.Parse(plain.URL)
	must(err)
	secureURL, err := url.Parse(secure.URL)
	must(err)
	check(plainURL.Hostname() == "127.0.0.1" && secureURL.Hostname() == "127.0.0.1", "IPv4 loopback fixtures required")
	request := func(scheme, name, port, route string, trust bool) (string, error) {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		args := []string{
			"--disable", "--noproxy", "*", "--silent", "--show-error", "--fail",
			"--max-time", "3", "--hsts", cacheFile, "--resolve", name + ":" + port + ":127.0.0.1",
		}
		if trust {
			args = append(args, "--cacert", caFile)
		}
		args = append(args, scheme+"://"+name+":"+port+route)
		command := exec.CommandContext(ctx, curl, args...)
		command.Env = append(os.Environ(), "SSLKEYLOGFILE=")
		output, err := command.CombinedOutput()
		return string(output), err
	}
	_, err = request("https", host, secureURL.Port(), "/learn", false)
	var exit *exec.ExitError
	check(errors.As(err, &exit) && exit.ExitCode() == 60, "untrusted HTTPS must fail certificate verification")
	for _, test := range []struct {
		label, scheme, name, port, route, expected string
	}{
		{"HTTP cannot seed HSTS", "http", host, plainURL.Port(), "/learn", "plain"},
		{"still HTTP", "http", host, plainURL.Port(), "/probe", "plain"},
		{"trusted HTTPS seeds HSTS", "https", host, secureURL.Port(), "/learn", "secure"},
		{"HTTP URL upgrades", "http", host, secureURL.Port(), "/probe", "secure"},
		{"child inherits upgrade", "http", child, secureURL.Port(), "/probe", "secure"},
		{"authenticated removal", "https", host, secureURL.Port(), "/clear", "secure"},
		{"parent HTTP restored", "http", host, plainURL.Port(), "/probe", "plain"},
		{"child HTTP restored", "http", child, plainURL.Port(), "/probe", "plain"},
	} {
		output, err := request(test.scheme, test.name, test.port, test.route, true)
		check(err == nil, fmt.Sprintf("curl failed: %v: %s", err, output))
		check(output == test.expected, "wrong transport result")
		fmt.Println(test.label, "->", output)
	}
	check(plainCount.Load() == 4 && secureCount.Load() == 4, "expected four plaintext and four authenticated TLS requests")
	fmt.Println(strings.SplitN(string(version), "\n", 2)[0])
	fmt.Println(runtime.Version())
}

All test hostnames resolve explicitly to loopback through –resolve; no public DNS or external site is used. –disable is curl’s first argument, so personal curl configuration is not loaded. –noproxy avoids configured proxies. Every curl child clears SSLKEYLOGFILE to disable inherited TLS key logging. Each transfer has a three-second limit and an outer five-second process deadline. The test CA is supplied per request, never installed globally.

The first HTTPS attempt lacks that CA and must fail with curl certificate-verification exit code 60. Plain HTTP then remains plain even after receiving an HSTS header. A trusted HTTPS response seeds the shared test cache; subsequent HTTP URLs for both the parent and child reach the TLS handler.

A trusted max-age=0 response removes the parent entry. Parent and child HTTP requests then reach the plaintext handler because this fixture never gives the child an independent policy. Exactly four plaintext and four authenticated TLS requests reach handlers; the failed certificate attempt reaches neither application handler. The nonstandard TLS port is preserved during upgrade.

All assertions passed on macOS with Go 1.27.0 and curl 8.7.1. This is real curl HSTS behavior, not a browser-policy acceptance test. It does not exercise browser preload lists, separate child policies, expiry waiting, port-80 mapping, certificate-error UI, or production rollout. The temporary cache intentionally disappears afterward; no persistent user HSTS setting is changed.

Further reading

Sources