golang/go · error

tls: internal error: empty verified chain

Error message

tls: internal error: empty verified chain

What it means

Thrown during serialization of a TLS SessionState (ticket.go:151) when one of the entries in s.verifiedChains has length zero. The marshaler elides the leaf cert and writes chain[1:], so an empty chain is structurally invalid. This is a defensive/internal error: a well-formed SessionState produced by the TLS stack always has at least the leaf in each verified chain.

Source

Thrown at src/crypto/tls/ticket.go:151

	} else {
		b.AddUint8(0)
	}
	if s.EarlyData {
		b.AddUint8(1)
	} else {
		b.AddUint8(0)
	}
	marshalCertificate(&b, Certificate{
		Certificate:                 certificatesToBytesSlice(s.peerCertificates),
		OCSPStaple:                  s.ocspResponse,
		SignedCertificateTimestamps: s.scts,
	})
	b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) {
		for _, chain := range s.verifiedChains {
			b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) {
				// We elide the first certificate because it's always the leaf.
				if len(chain) == 0 {
					b.SetError(errors.New("tls: internal error: empty verified chain"))
					return
				}
				for _, cert := range chain[1:] {
					b.AddUint24LengthPrefixed(func(b *cryptobyte.Builder) {
						b.AddBytes(cert.Raw)
					})
				}
			})
		}
	})
	if s.EarlyData {
		b.AddUint8LengthPrefixed(func(b *cryptobyte.Builder) {
			b.AddBytes([]byte(s.alpnProtocol))
		})
	}
	if s.version >= VersionTLS13 {
		if s.isClient {
			addUint64(&b, s.useBy)

View on GitHub (pinned to b6b368adc5)

Solutions

  1. Do not hand-build SessionState.verifiedChains; obtain SessionState from the stack via ClientSessionState/ServerSessionState and round-trip it with Bytes()/ParseSessionState().
  2. If you must construct it, ensure every entry in verifiedChains is non-empty and starts with the leaf certificate (the marshaler elides chain[0]).
  3. Validate verifiedChains before serializing: for each chain check len(chain) > 0 and chain[0] != nil.
  4. If the state came from a cache, treat corruption as a cache miss: drop the ticket and force a full handshake.

Example fix

// before
ss := &tls.SessionState{
    verifiedChains: [][]*x509.Certificate{{}}, // empty chain -> error
}
data, err := ss.Bytes()

// after
ss := &tls.SessionState{
    verifiedChains: [][]*x509.Certificate{{leafCert, intermediateCert}},
}
data, err := ss.Bytes()
Defensive patterns

Strategy: validation

Validate before calling

// Before calling ss.Bytes(), ensure every verified chain is non-empty.
func validVerifiedChains(ss *tls.SessionState) bool {
    for _, chain := range ss.verifiedChains {
        if len(chain) == 0 {
            return false
        }
        for _, c := range chain {
            if c == nil || len(c.Raw) == 0 {
                return false
            }
        }
    }
    return true
}

Prevention

When it happens

Trigger: Calling (*SessionState).Bytes() (via SessionState.Bytes / session ticket marshaling) on a SessionState whose verifiedChains slice contains an empty []string/[]*x509.Certificate entry. Most often reached when application code hand-constructs a SessionState and appends an empty chain, or when a custom ClientSessionCache/ServerSessionCache stores a corrupted state.

Common situations: Manually building a SessionState for testing or for a custom ticket key rotation scheme; downgrading/transforming session data between Go versions; bugs in resumption code that mutate verifiedChains; importing session blobs from non-Go TLS implementations that omit chain construction.

Understand the failure class

Related errors


AI-assisted analysis of golang/go@b6b368adc5 (2026-08-12). Data as JSON: /api/errors/ace08fbe529f89c5. Report an issue: GitHub.