A certificate’s expiry is a deadline for certificate validation, not a timer that necessarily closes every existing connection. Read the certificate actually served to the intended hostname, then distinguish its validity window from the rest of the trust decision. A future notAfter date alone does not establish that HTTPS is healthy.

Compare instants, not calendar labels

RFC 5280 defines validity from notBefore through notAfter, inclusive. A verifier must reject a certificate outside that interval; the exact notAfter instant remains inside it. Waiting for the final second is nevertheless an operational mistake. Clock uncertainty, retries, and rollout time require a margin comfortably before the boundary.

The fields describe absolute times. UTC makes logs easier to compare, but two correctly parsed timestamps can use different timezone representations and still identify the same instant. Subtract instants using a time library. Comparing local date strings or rounded remaining days can hide a not-yet-valid certificate or postpone an urgent warning.

Inspect the public edge with the correct SNI and expected hostname, not only a certificate file on the origin. Different listeners, regions, or load-balancer nodes can serve different certificates. Public certificate inspection needs no private key, but merely retrieving dates is not equivalent to validating the chain, hostname, usage, and applicable client policies.

When it breaks

Renewal can succeed while deployment fails: the new certificate exists on disk, but a process still serves the previous one. Compare the served fingerprint and validity dates after reload, including all relevant edges. Also inspect the chosen chain: a valid leaf can sit below an expired intermediate and fail verification.

Clock skew may make a fresh certificate appear not yet valid. A monitor that turns a network timeout or parsing failure into an empty expiry value must not report healthy. Distinguish unavailable evidence from a successfully verified, sufficiently long validity window, and keep an owner and escalation path for each monitored endpoint.

Choose renewal and warning schedules from the actual certificate lifetime and recovery needs, not a universal thirty-day constant. Let’s Encrypt recommends using ACME Renewal Information when available and spreading renewal work. Monitor issuance failures separately from served-certificate expiry. Renewal does not automatically rotate every private key, and expiry monitoring is not revocation checking.

Check it quickly

Save this as expiry_probe.go and run go run expiry_probe.go with Go 1.23 or newer. The program generates an isolated root, intermediate, and leaf in memory. Verification uses an explicit test trust store, hostname, server-authentication usage, and injected time. The synthetic clock starts at 2026-09-09 00:00:00 UTC; it never changes the machine’s clock.

package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"errors"
	"fmt"
	"math/big"
	"runtime"
	"time"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

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

func key() (ed25519.PublicKey, ed25519.PrivateKey) {
	public, private, err := ed25519.GenerateKey(rand.Reader)
	must(err)
	return public, private
}

func issue(serial int64, parent *x509.Certificate, public ed25519.PublicKey, signer ed25519.PrivateKey, start, end time.Time, ca bool) *x509.Certificate {
	template := &x509.Certificate{
		SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: fmt.Sprint("fixture-", serial)},
		NotBefore: start, NotAfter: end, IsCA: ca, BasicConstraintsValid: true,
	}
	if ca {
		template.KeyUsage = x509.KeyUsageCertSign
	} else {
		template.KeyUsage = x509.KeyUsageDigitalSignature
		template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
		template.DNSNames = []string{"expiry.example.test"}
	}
	if parent == nil {
		parent = template
	}
	der, err := x509.CreateCertificate(rand.Reader, template, parent, public, signer)
	must(err)
	certificate, err := x509.ParseCertificate(der)
	must(err)
	return certificate
}

func main() {
	base := time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC)
	rootPublic, rootPrivate := key()
	issuerPublic, issuerPrivate := key()
	leafPublic, _ := key()
	root := issue(1, nil, rootPublic, rootPrivate, base.Add(-24*time.Hour), base.Add(24*time.Hour), true)
	issuer := issue(2, root, issuerPublic, rootPrivate, base.Add(-time.Hour), base.Add(3*time.Hour), true)
	leaf := issue(3, issuer, leafPublic, issuerPrivate, base, base.Add(2*time.Hour), false)
	roots, intermediates := x509.NewCertPool(), x509.NewCertPool()
	roots.AddCert(root)
	intermediates.AddCert(issuer)
	verify := func(certificate *x509.Certificate, instant time.Time, name string) error {
		_, err := certificate.Verify(x509.VerifyOptions{
			Roots: roots, Intermediates: intermediates, CurrentTime: instant, DNSName: name,
			KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		})
		return err
	}
	expired := func(err error, certificate *x509.Certificate) {
		var invalid x509.CertificateInvalidError
		check(errors.As(err, &invalid) && invalid.Reason == x509.Expired &&
			invalid.Cert.SerialNumber.Cmp(certificate.SerialNumber) == 0, "wrong validity failure")
	}
	for _, test := range []struct {
		label   string
		instant time.Time
		valid   bool
	}{
		{"before", base.Add(-time.Second), false},
		{"notBefore", base, true},
		{"middle", base.Add(time.Hour), true},
		{"notAfter", base.Add(2 * time.Hour), true},
		{"after", base.Add(2*time.Hour + time.Second), false},
	} {
		err := verify(leaf, test.instant, "expiry.example.test")
		if test.valid {
			must(err)
		} else {
			expired(err, leaf)
		}
		fmt.Println(test.label, "valid:", err == nil)
	}
	longLeaf := issue(4, issuer, leafPublic, issuerPrivate, base, base.Add(6*time.Hour), false)
	expired(verify(longLeaf, base.Add(4*time.Hour), "expiry.example.test"), issuer)
	var hostname x509.HostnameError
	check(errors.As(verify(leaf, base.Add(time.Hour), "wrong.example.test"), &hostname), "wrong name must fail")
	sameInstant := base.In(time.FixedZone("fixture+08", 8*60*60))
	must(verify(leaf, sameInstant, "expiry.example.test"))
	check(leaf.NotAfter.Sub(sameInstant) == 2*time.Hour, "timezone must not change the instant")
	remaining := leaf.NotAfter.Sub(base.Add(90 * time.Minute))
	check(remaining == 30*time.Minute && remaining <= 45*time.Minute, "fixture alert must fire")
	fmt.Println("issuer expiry and wrong name rejected; timezone preserves instant; remaining=30m triggers 45m warning")
	fmt.Println(runtime.Version())
}

The five validity results must be false, true, true, true, false. The ordinary leaf ends at hour two, its intermediate at hour three, and its root at hour twenty-four after the base. Consequently, both leaf endpoints can be checked without an unrelated issuer expiry masking the expected result. Negative assertions check the error reason and the affected certificate’s serial number.

A second leaf lasts six hours. At hour four, that leaf is still within its window but its intermediate is expired, so the chain fails on the issuer. A separate request to verify the ordinary leaf for the wrong DNS name fails with HostnameError while time validity remains satisfied.

Representing the base instant in a fixed UTC+08 timezone does not change verification or remaining duration. At ninety minutes, the ordinary leaf has thirty minutes left, which triggers this fixture’s forty-five-minute warning. Those tiny intervals are test parameters, not recommended production schedules. Successful execution prints labels and the Go version, never private keys.

All assertions passed with Go 1.27.0 on macOS. This is X.509 verification, not a network TLS handshake or an automated renewal deployment. Go’s Verify does not perform revocation checking. The exercise tests selected chain and clock boundaries; it does not inspect public edges, browser-specific policies, OCSP, CRLs, or session-resumption behavior. Existing connections need their own lifecycle policy.

Further reading

Sources