package main import ( "crypto/tls" "crypto/x509" "encoding/pem" "errors" "net" "os" "time") // fileNotAfter reads the first certificate of a PEM file.func fileNotAfter(path string) (time.Time, error) { data, err := os.ReadFile(path) if err != nil { return time.Time{}, err } block, _ := pem.Decode(data) if block == nil || block.Type != "CERTIFICATE" { return time.Time{}, errors.New(path + ": no CERTIFICATE block") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return time.Time{}, err } return cert.NotAfter, nil // UTC} // serverNotAfter connects with full verification and returns the leaf's expiry// and the earliest expiry along the verified chain.func serverNotAfter(addr, serverName string, roots *x509.CertPool) (leaf, chain time.Time, err error) { dialer := &net.Dialer{Timeout: 10 * time.Second} conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{RootCAs: roots, ServerName: serverName}) if err != nil { return leaf, chain, err } defer conn.Close() state := conn.ConnectionState() leaf, chain = state.PeerCertificates[0].NotAfter, state.PeerCertificates[0].NotAfter for _, cert := range state.VerifiedChains[0] { if cert.NotAfter.Before(chain) { chain = cert.NotAfter } } return leaf, chain, nil} package main import ( "crypto/tls" "crypto/x509" "fmt" "log" "os" "time") func main() { for _, path := range []string{"leaf.pem", "expired.pem"} { notAfter, err := fileNotAfter(path) if err != nil { log.Fatal(err) } days := int(time.Until(notAfter).Hours() / 24) fmt.Printf("%s: notAfter %s, %d days left\n", path, notAfter.Format(time.RFC3339), days) } roots := x509.NewCertPool() // only our test root; nil would mean the system roots rootPEM, err := os.ReadFile("root.pem") if err != nil || !roots.AppendCertsFromPEM(rootPEM) { log.Fatal("cannot load root.pem") } leaf, chain, err := serverNotAfter("api.example.com:8443", "api.example.com", roots) if err != nil { log.Fatal(err) } fmt.Println("api.example.com: leaf", leaf.Format(time.RFC3339), "chain", chain.Format(time.RFC3339)) // An expired certificate fails verification, so read it without verifying — only to look at it _, _, err = serverNotAfter("expired.example.com:9443", "expired.example.com", roots) fmt.Println("verified:", err) conn, err := tls.Dial("tcp", "expired.example.com:9443", &tls.Config{InsecureSkipVerify: true}) if err != nil { log.Fatal(err) } defer conn.Close() fmt.Println("unverified: notAfter", conn.ConnectionState().PeerCertificates[0].NotAfter.Format(time.RFC3339))} How it works
pem.Decodereturns the first block;x509.ParseCertificate(block.Bytes)gives a*x509.CertificatewhoseNotAfteris atime.Timein UTC.tls.DialWithDialerwithRootCAsandServerNameverifies the chain and the host name (and sends SNI); a nilRootCAsmeans the system roots.VerifiedChains[0]is the chain Go built to a trusted root. The chain stops working as soon as any of its certificates expires, soserverNotAfteralso returns the earliestNotAfteralong it.time.Until(notAfter)gives the remaining duration; the run prints2035-12-31T23:59:59Zfor the leaf from the file and from the local test server.
Gotchas
- A verifying
tls.Dialto an expired certificate fails withtls: failed to verify certificate: x509: certificate has expired or is not yet valid: current time … is after 2025-03-31T23:59:59Z— the date is in the error, but you get noConnectionState. - To inspect such a certificate, dial with
InsecureSkipVerify: trueand readPeerCertificates[0]: the date is there (2025-03-31T23:59:59Z), the chain is not verified, and the connection must carry nothing else. int(time.Until(t).Hours() / 24)truncates toward zero: the expired certificate shows-567days where flooring gives-568. Comparetime.Timevalues (notAfter.Before(deadline)) instead of day counts when the boundary matters.pem.Decodesilently returnsnilfor a file without PEM blocks — check it, asfileNotAfterdoes, before callingParseCertificate.