mTLS in Go: my refusal tests passed for the wrong reason

Table of contents (10 sections)

I am writing a small HTTPS server in Go that requires a certificate from every client. My tests check that it refuses the bad clients. All seven refusal tests passed on the first run. My legitimate client, however, was refused like the others.

TLS is the protocol that encrypts HTTPS connections, the one behind the browser padlock. With mTLS, for "mutual TLS", the server shows its certificate to the client, and the client shows its own. The server is the program that answers, the client the one that connects. A certificate is a digital identity card, guaranteed by a certificate authority (CA). The CA signs the certificate with a secret key that only it holds. Anyone can then check that signature with the matching public key. The server only lets through clients whose certificate is signed by a CA it knows.

Each test starts this server and sends it a client. On the first run, all seven faulty clients were refused: green tests, so passing. The legitimate client was refused too, and the server had not even looked at the clients' certificates. The seven refusals came from somewhere else.

This article covers five traps in mTLS tests in Go. In the first two, a refusal test passes without the server having judged. The third makes a refused client believe it was accepted. The last two concern revocation. I verified them in the code of Go 1.26.10 and with tests that start a real server and real clients. Two TLS versions are still in use, 1.2 and the newer 1.3, and the traps differ between them. I number them in the headings.

$ git clone https://github.com/ohugonnot/mtls-refuse
$ cd mtls-refuse && go test -race -v ./e2e

ohugonnot/mtls-refuse

A Go server that requires a client certificate, and a small CA that makes faulty certificates on demand. Each test starts a real server and a real client, then reads what the server writes to its log. Each line below says what is proven, and by what. "Mutation" means that we break the code on purpose to see whether a test fails.

  • VerifiedA legitimate client passes. Seven faulty clients are refused, each for the expected reason, in TLS 1.2 as well as TLS 1.3: TestMatrix.
  • VerifiedThe tests fail when the code is broken in ten ways, one at a time. Trial run on November 6, 2026, on Go 1.24.13 and 1.26.10: ./mutations.sh.
  • VerifiedCI (GitHub's bot that reruns the tests on every change) is green on Go 1.24 and 1.26: run 42303001046.
  • Not verifiedOCSP (a service that answers live "is this certificate revoked?"), intermediate authorities, HTTP/2 and systems other than Linux: the repo does not cover them, or does not test them.

Trap 1: a passing refusal does not tell you who refused

My tests reached the server through its IP address, 127.0.0.1, which designates the computer itself. But a certificate is only valid for the names and addresses it carries. The server's certificate carried the name localhost, which designates the same machine but not that address. The Go client checks the server's certificate before presenting its own. It rejected it, with this error: cannot validate certificate for 127.0.0.1 because it doesn't contain any IP SANs. The server never saw a single client.

My seven refusal tests expected an error, and they got one. So they passed. Only the legitimate client, which expected success, failed. Without that legitimate client, everything would have been green.

Two rules follow. You need a control client: a legitimate client that must pass. You must also confirm each refusal by the reason the server writes to its log, not by the client's error. The following function waits, for up to one second, until the log contains a given text:

func (e *env) waitLog(want string) bool {
	for i := 0; i < 100; i++ {
		if strings.Contains(e.logs.String(), want) {
			return true
		}
		time.Sleep(10 * time.Millisecond)
	}
	return false
}

Source code: e2e/helpers_test.go, lines 49 to 57

My tests form a matrix: a table of cases, one faulty client per row. Each row names the text it expects in the log: "unknown authority", "is after", "révoqué". If the client is refused for another cause, the test fails and shows the client's error and the server's log.

What Go checks on its own, and what it never checks

The server configuration, a tls.Config object from the standard library, fits in a few lines:

func ServerConfig(server tls.Certificate, clientCAs *x509.CertPool, rev *Revocation) *tls.Config {
	return &tls.Config{
		Certificates:     []tls.Certificate{server},
		ClientAuth:       tls.RequireAndVerifyClientCert,
		ClientCAs:        clientCAs,
		MinVersion:       tls.VersionTLS12,
		VerifyConnection: rev.checkState,
	}
}

Source code: gate/gate.go, lines 111 to 119

RequireAndVerifyClientCert tells Go to require a certificate, and ClientCAs gives it the trusted CAs to compare against. Go then checks the signature, the dates and the client usage. Each certificate declares whether it serves a client, a server, or both. The last line, VerifyConnection, plugs in my own check function, which I detail below. The table shows, for each of the eight test clients in the repo, what the server writes. These are the corrected clients from trap 2 below: they always send their certificate. The messages are Go's, in English, followed by a short gloss in brackets.

ClientWhat the server writes to its log
Legitimate (control client)nothing: the server answers normally
No certificateclient didn't provide a certificate (the client sent nothing)
Other CAcertificate signed by unknown authority (unknown CA)
Twin CA: a CA with the same name as the real one, but another key pairunknown authority … ECDSA verification failure (the signature does not verify; ECDSA is the algorithm of my test certificates)
Expired certificatecertificate has expired or is not yet valid: current time … is after … (the current time is after the end date)
Not-yet-valid certificatecertificate has expired or is not yet valid: current time … is before … (the current time is before the start date)
Server-only certificatecertificate specifies an incompatible key usage (the declared usage does not fit)
Revoked certificatecertificat 101 révoqué (my own message, not Go's; 101 is the certificate's serial number)

The revoked row is the only one that Go does not write itself. A certificate can be declared dead before its end date: stolen key, employee gone. This is revocation. The CA then publishes a revocation list (CRL), which lists the cancelled certificates. It identifies them by their serial number, a number the CA gives each certificate it signs. x509.Certificate.Verify is the Go function that checks a certificate. Its documentation warns in one line: "WARNING: this function doesn't do any revocation checking". In other words, it never checks revocation.

The crypto/tls package, which implements TLS in Go, contains no revocation check either. It only passes along a "stapled" OCSP response, attached to the certificate by the server, without judging it. So you have to write that check yourself, and the rest of this article covers it.

A note on context: Let's Encrypt, the free public CA, announced it would stop issuing client certificates in 2026. For mTLS, you therefore need your own CA and your own list. The dates are in the sources.

Trap 2: the Go client that stays silent instead of presenting its certificate

My test "certificate from another authority" also passed for the wrong reason. The server wrote "the client did not provide any certificate". The certificate had never arrived.

When the server asks for a certificate, it sends the list of CA names it accepts. The Go client has a configuration field, Certificates, which lists its own certificates. With this field, Go compares the issuer name of each certificate with the server's list. If no name matches, the client sends nothing, and no error is raised (crypto/tls/handshake_client.go, line 1286: "No acceptable certificate found. Don't send a certificate.").

This field filters, so it skews my tests. Another option exists, GetClientCertificate, which filters nothing: you give it a certificate, and it sends it. My test clients use it:

	if cert != nil {
		cfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return cert, nil }
	}

Source code: e2e/helpers_test.go, lines 110 to 112

There is one weakness left in this filter: it compares names. But a twin CA, defined in the table above, carries the same name as your real CA. Its certificate therefore passes the client's filter. The server then checks the signature with the real CA's key, and refuses the certificate. This is the only case in the matrix whose refusal comes from a signature that does not verify. Its log contains "ECDSA verification failure". I read it, but the test does not require it: it only requires "unknown authority", as for the other CA. Here, my test is less precise than the log it reads.

Trap 3: in TLS 1.3, the refused client connects without error

Imagine a test that presents an expired certificate with tls.Dial, the Go function that opens a TLS connection, and expects an error. In TLS 1.2, the error arrives. In TLS 1.3, Dial succeeds.

The handshake is the exchange that sets up the encrypted connection. In TLS 1.3, the client sends its certificate and considers itself done, without waiting for the server to judge it. The server then judges and sends an alert, a TLS error message. The client only sees it on its first read, that is, the first time it reads the server's reply. In my test, it carries the text "remote error: tls: expired certificate".

Two public discussions in the Go project, called issues, describe this behavior. #32202 is titled "Dial to server with invalid client certificates succeeds and allows writes on tip, but not in 1.12". In short, Dial succeeds with an invalid client certificate, and writes go through, on tip (the development version) but not in 1.12. #56371 is titled "client tls 1.3 handshake doesn't return bad certificate error". Practical consequence: a client, or a test, that stops at Dial believes it was accepted. The test TestTLS13RefusalArrivesAfterHandshake checks both behaviors, the TLS 1.2 one and the TLS 1.3 one.

Trap 4: a revocation check in the wrong place is skipped by session resumption

After a first connection, the server gives the client a session ticket. On the next visit, the client presents that ticket, and the two recognize each other without redoing the whole handshake. This is session resumption, or simply resumption. Go offers two functions to plug in a certificate check. Their documentation says they differ on resumptions:

  • VerifyPeerCertificate is not called on a resumption ("This callback is not invoked on resumed connections");
  • VerifyConnection, the one in my configuration, runs for every connection, resumptions included ("This callback will run for all connections, including resumptions").

If you put the revocation check in VerifyPeerCertificate, a certificate revoked between two connections gets through again on resumption. The ticket keeps the client's chain: its certificate, then the one of the CA that signed it. On resumption, Go checks that the dates of these certificates are still valid. It also checks that this CA is still among the trusted CAs. Otherwise, it discards the ticket and performs a full handshake again (handshake_server_tls13.go, lines 370 to 381). It never looks at revocation, here or anywhere else in crypto/tls.

The test shows it in three connections. The first is a full handshake. The second is a resumption, and the test confirms it with DidResume, a Go flag that tells whether the connection is a resumption. Without this check, the test could pass without ever testing a resumption. Then I revoke the certificate, and the third connection is refused, with "révoqué" in the log. For the mutation, I moved my check from VerifyConnection to VerifyPeerCertificate. Only one test failed: that one.

A connection that stays open (keep-alive) raises the same problem: it performs no handshake at all. The book Zero Trust Networks (2nd edition, O'Reilly 2024) gives the principle in chapter 3, without mentioning TLS. It covers zero-trust security, where no device is trusted on its word. Here is its sentence: "The act of validating an authenticated session is really an authorization decision". In other words, re-checking a connection that is already authenticated is an authorization decision, not just an identity check. The book says "session"; in my case, it is the connection left open.

My test TestKeepAliveNeedsPerRequestCheck measures it, on TLS 1.2. Without a check on each request, the open connection keeps receiving code 200 ("it works") after the revocation. With a small middleware (gate.Middleware), a piece of code that runs before each request, the next request gets 403 ("forbidden"). The principle behind this double check, at the handshake and then at each request, is detailed in my article on the double-gate CRL.

Trap 5: the revocation list must be verified too

That leaves the list. A server that loads any list can be fooled: a fake list revokes your clients, an old list reactivates a revoked certificate. My code keeps the latest list of each CA in memory, in a revocation registry. It applies five rules. The first four reject a doubtful case:

  • a list signed with a key other than that of a trusted CA, even under the right CA name (TestForgedCRLIsRejectedAndChangesNothing);
  • a list whose sequence number is lower than or equal to that of the list in place. Each list carries such a number, which has nothing to do with the serial number of a certificate. A cache placed between you and the CA can serve an old, empty list again (TestRolledBackCRLIsRejected);
  • a stale list: its next update date has passed, so everything is refused (TestStaleCRLFailsClosed);
  • no list for a CA: that is refused too (TestNoCRLFailsClosed).

The last two choices close the door when information is missing. The cost: if the list stops reloading, your legitimate clients are cut off. I accept that for a server that protects something.

The fifth rule is the one my previous article on the CRL did not follow. It revoked a certificate by its serial number alone. But two different CAs can issue the same number. So the repo's registry files each list under the identity of its CA, that is, its public key. It looks up the serial number only in the list of the CA that signed the certificate. In the code, leaf is the client's certificate and issuer the one of its CA, the second in the chain:

	leaf, issuer := chain[0], chain[1]
	r.mu.RLock()
	defer r.mu.RUnlock()
	l := r.lists[idOf(issuer)]
	if l == nil {
		return errors.New("aucune liste de révocation pour cette autorité")
	}
	if !l.nextUpdate.After(r.now()) {
		return errors.New("liste de révocation périmée")
	}
	if l.revoked[leaf.SerialNumber.String()] {
		return fmt.Errorf("certificat %s révoqué", leaf.SerialNumber)
	}

Source code: gate/gate.go, lines 86 to 98

TestSameSerialInTwoAuthorities creates two CAs with the same name, each with a certificate numbered 7, and revokes only the first one's certificate. The first CA's certificate is refused, the second's passes. The serial number alone would have confused them.

My take: a refusal test must write down its reason

My take: an mTLS test that only checks that an error occurred proves almost nothing. It passes when the client forgets its certificate, when the server's name does not match, or when the server's certificate has expired. Each refusal must name the sentence the server writes.

The first reflex is therefore to write the reason. The second is to break the code to see. The repo contains ten mutations: remove the certificate requirement, move the verification, ignore the list's signature. All of them are caught. The one that moves revocation into VerifyPeerCertificate is caught only by the resumption test. Without that mutation, I would have believed the matrix complete.

Conclusion

In traps 1 to 3, the client receives an error, or nothing, and concludes too quickly. The server, for its part, wrote down what it decided. In traps 4 and 5, it is the server that wrongly accepted. A test that fails when you break the code shows it. I no longer trust a refusal test until I have read the server's reason and seen the test fail on a mutation.

The books behind this article

  • Zero Trust Networks, 2nd Edition Rais, Morillo, Gilman and Barth, O'Reilly 2024.

    Used: the chapter 3 sentence on validating an authenticated session, which is an authorization decision. Not in the book: revocation of client certificates in practice. The acronyms "CRL" and "OCSP" appear nowhere in it.

Sources

Documentation consulted on November 6, 2026:

In the Developer's Great Book

A free book where each idea is told once, in its place, from silicon to human judgment.

Comments (0)