Require a client certificate¶
Mutual TLS, from nothing to a working handshake: a local certificate authority, a server that refuses anyone without a certificate it issued, and a Go client that presents one. About twenty minutes.
You'll need Go 1.26 or newer, openssl, and curl to prove the rejection. If you
have already worked through Serve HTTPS with hardened
defaults you'll recognise the server; if not, you can still
follow along from here.
Everything you generate is a throwaway. The CA key produced below can sign certificates that your server will accept, so delete the directory when you're done rather than leaving it in a repository.
Create a certificate authority¶
With mutual TLS both ends prove who they are, which means both ends need a certificate signed by something the other trusts. One CA signing both is the simplest arrangement that works.
mkdir -p mtls-demo/certs && cd mtls-demo
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout certs/ca-key.pem -out certs/ca.pem -days 365 -subj "/CN=demo-ca"
certs/ca.pem is the trust anchor both sides will be given. certs/ca-key.pem is
the thing that must not leak.
Issue a server certificate¶
The server certificate needs a SAN for localhost — clients check the SAN, not the
common name — and the serverAuth extended key usage.
cat > certs/server.ext <<'EOF'
subjectAltName = DNS:localhost, IP:127.0.0.1
extendedKeyUsage = serverAuth
EOF
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout certs/server-key.pem -out certs/server.csr -subj "/CN=localhost"
openssl x509 -req -in certs/server.csr -CA certs/ca.pem -CAkey certs/ca-key.pem \
-CAcreateserial -out certs/server.pem -days 365 -extfile certs/server.ext
Issue a client certificate¶
Same CA, but clientAuth instead of serverAuth, and no SAN — nobody connects to
a client. The common name is how the server will identify the caller.
cat > certs/client.ext <<'EOF'
extendedKeyUsage = clientAuth
EOF
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout certs/client-key.pem -out certs/client.csr -subj "/CN=demo-client"
openssl x509 -req -in certs/client.csr -CA certs/ca.pem -CAkey certs/ca-key.pem \
-CAcreateserial -out certs/client.pem -days 365 -extfile certs/client.ext
Confirm the extended key usage came out right. A certificate carrying the wrong one
— serverAuth, which is easy to copy across by accident — is rejected at handshake
time with x509: certificate specifies an incompatible key usage. A certificate with
no extended key usage at all is accepted, so the mistake to look for is the wrong
value rather than a missing one:
Write the server that demands one¶
Two extra fields on the Pair turn mutual TLS on. ClientCAs lists the PEM files a
client certificate must chain to; ClientAuth says how hard to insist.
// server/main.go
package main
import (
"fmt"
"log"
"net/http"
"gitlab.com/phpboyscout/go/tls"
)
func main() {
pair := tls.Pair{
Enabled: true,
Cert: "certs/server.pem",
Key: "certs/server-key.pem",
ClientCAs: []string{"certs/ca.pem"},
ClientAuth: tls.ClientAuthRequireVerify,
}
cfg, err := pair.ServerConfig("h2", "http/1.1")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
name := r.TLS.PeerCertificates[0].Subject.CommonName
fmt.Fprintf(w, "hello, %s\n", name)
})
srv := &http.Server{Addr: "127.0.0.1:8443", TLSConfig: cfg, Handler: mux}
log.Println("listening on https://localhost:8443")
log.Fatal(srv.ListenAndServeTLS("", ""))
}
tls.ClientAuthRequireVerify is the constant for the string "require-verify", and
it is the strict mode: a certificate is required and it must verify. The handler
indexes r.TLS.PeerCertificates[0] without checking the length, which is only safe
because of that mode — under request or verify-if-given the slice can be empty
and that line panics.
Leaving ClientAuth out entirely would have given you the same behaviour, because a
pair with ClientCAs set and no mode defaults to require-verify. Saying it out
loud is worth the line.
Set it up and start it:
Watch a client without a certificate get turned away¶
curl: (56) OpenSSL SSL_read: OpenSSL/3.0.13: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
The server prints its side of the same event:
2026/08/02 18:35:30 http: TLS handshake error from 127.0.0.1:41682: tls: client didn't provide a certificate
curl trusts the server here — it was given --cacert — and is still refused, because
trust runs both ways now. Hand it the client certificate and it gets through:
curl --cacert certs/ca.pem --cert certs/client.pem --key certs/client-key.pem \
https://localhost:8443/
Present a client certificate from Go¶
This is the step where the package stops helping and you have to do one thing by
hand. ClientConfig sets up trust — which servers this client will accept — and
has no parameter for the client's own certificate. Load that with Pair.Certificate
and assign it:
// client/main.go
package main
import (
cryptotls "crypto/tls"
"fmt"
"io"
"log"
"net/http"
"gitlab.com/phpboyscout/go/tls"
)
func main() {
cfg, err := tls.ClientConfig("certs/ca.pem")
if err != nil {
log.Fatal(err)
}
clientPair := tls.Pair{
Enabled: true,
Cert: "certs/client.pem",
Key: "certs/client-key.pem",
}
cert, err := clientPair.Certificate()
if err != nil {
log.Fatal(err)
}
cfg.Certificates = []cryptotls.Certificate{cert}
client := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
resp, err := client.Get("https://localhost:8443/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Print(string(body))
}
The import alias is deliberate: this package is also called tls, so one of the two
has to be renamed. The convention here is to rename the standard library.
ClientCAs and ClientAuth on the client's own pair would do nothing, incidentally
— they are server-side fields, consumed only when building a server config.
Loosen it for a mixed-traffic listener¶
require-verify is all-or-nothing. A listener that serves both certificate-holding
services and ordinary browsers wants verify-if-given instead:
A client that presents a certificate must still chain to ClientCAs; a client that
presents none is let through, and r.TLS.PeerCertificates is empty. Your handler
decides what an unauthenticated caller is allowed to do — check the length before you
index it.
There is a third mode, request, which asks for a certificate and then does not
verify it. Anything it collects is unauthenticated input; treat a common name from
that mode as a hint, never as an identity.
Where to go next¶
- Configuration keys — what each
client_authvalue enforces, and what a wrong one does. - Errors — the handshake failures you'll meet and what each one means.
- Harden a server & client — the same thing as a recipe, without the walkthrough.
- Requiring client certificates fails closed — how ambiguous combinations resolve, and the one case that does not.