Skip to content

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
Certificate request self-signature ok
subject=CN = localhost

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:

openssl x509 -in certs/client.pem -noout -subject -ext extendedKeyUsage
subject=CN = demo-client
X509v3 Extended Key Usage:
    TLS Web Client Authentication

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:

go mod init mtlsdemo
go get gitlab.com/phpboyscout/go/tls
go run ./server

Watch a client without a certificate get turned away

curl --cacert certs/ca.pem https://localhost:8443/
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/
hello, demo-client

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.

go run ./client
hello, demo-client

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:

ClientAuth: tls.ClientAuthVerifyIfGiven,

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