Mutual TLS authenticates a client’s control of a certificate’s private key as well as the server’s identity. It does not inherently identify a physical device. A copied software key can be used elsewhere; hardware binding and device attestation require additional mechanisms. Treat the title’s device identity as an enrollment policy, not something TLS establishes automatically.
Authentication still needs authorization
During certificate-based TLS 1.3 client authentication, the server requests a certificate and the client proves control of the corresponding private key through CertificateVerify. Sending certificate bytes alone is insufficient. Both peers must apply their intended trust rules, including chain validity and appropriate certificate usage; the client must also verify the server’s name.
The server’s client-CA trust pool and the client’s server trust pool serve different directions. Trust only the intended issuers, and define which verified certificate field identifies a workload. A URI SAN can supply an application identifier, but the issuer’s enrollment rules determine who may receive it. A name in a certificate is not automatically permission to call every API.
Requiring a certificate is also different from merely requesting one. In Go, RequireAndVerifyClientCert requires a valid client certificate; optional modes can allow a connection without one. After authentication, apply authorization to the verified identity. A valid guest may deserve HTTP 403 rather than an authenticated reader’s HTTP 200.
When it breaks
A missing certificate, unknown issuer, expired credential, or inappropriate extended key usage can prevent client authentication. Changing the server policy to accept any certificate or disabling server verification conceals the boundary instead of repairing the credential. Separate handshake failures from application denials so that operators investigate the right layer.
Private keys need controlled storage, issuance, rotation, and incident response. Certificate renewal does not necessarily replace the key. A stolen usable key may support impersonation until acceptance is withdrawn; waiting for expiry is not the only response. Go’s certificate Verify does not perform revocation checking, so do not assume a published revocation is automatically enforced.
TLS termination also changes where identity is established. A backend receiving a caller-controlled identity header has not thereby verified the caller’s certificate. Any trusted identity propagation must have its own authenticated boundary and spoofing protections. Existing connections and resumed sessions additionally need explicit lifecycle policy when credentials or permissions change.
Check it quickly
Save this as mtls_probe.go and run go run mtls_probe.go with Go 1.23 or newer. It generates client CAs and credentials in memory, backdating notBefore by one minute and setting notAfter one hour ahead. The loopback server uses httptest’s test server certificate; the supplied test client trusts that certificate. Neither trust store is installed globally, and private keys are never printed or written.
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func authority(serial int64) (*x509.Certificate, ed25519.PrivateKey) {
public, private, err := ed25519.GenerateKey(rand.Reader)
must(err)
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: fmt.Sprint("fixture-ca-", serial)},
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, public, private)
must(err)
certificate, err := x509.ParseCertificate(der)
must(err)
return certificate, private
}
func credential(serial int64, identity string, usage x509.ExtKeyUsage, ca *x509.Certificate, signer ed25519.PrivateKey) tls.Certificate {
public, private, err := ed25519.GenerateKey(rand.Reader)
must(err)
name, err := url.Parse("urn:example:" + identity)
must(err)
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(serial), URIs: []*url.URL{name},
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage},
}
der, err := x509.CreateCertificate(rand.Reader, template, ca, public, signer)
must(err)
certificate, err := x509.ParseCertificate(der)
must(err)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: private, Leaf: certificate}
}
func main() {
ca, signer := authority(1)
foreignCA, foreignSigner := authority(2)
reader := credential(3, "reader", x509.ExtKeyUsageClientAuth, ca, signer)
guest := credential(4, "guest", x509.ExtKeyUsageClientAuth, ca, signer)
foreign := credential(5, "reader", x509.ExtKeyUsageClientAuth, foreignCA, foreignSigner)
serverOnly := credential(6, "reader", x509.ExtKeyUsageServerAuth, ca, signer)
roots := x509.NewCertPool()
roots.AddCert(ca)
options := x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}
_, err := foreign.Leaf.Verify(options)
var unknown x509.UnknownAuthorityError
check(errors.As(err, &unknown), "foreign issuer fixture must fail trust")
_, err = serverOnly.Leaf.Verify(options)
var invalid x509.CertificateInvalidError
check(errors.As(err, &invalid) && invalid.Reason == x509.IncompatibleUsage, "server-only fixture must fail client usage")
var handled atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
check(len(r.TLS.VerifiedChains) > 0, "handler requires a verified client chain")
identities := r.TLS.VerifiedChains[0][0].URIs
if len(identities) != 1 || identities[0].String() != "urn:example:reader" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
fmt.Fprintln(w, "reader allowed")
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.TLS = &tls.Config{
MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13,
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: roots, SessionTicketsDisabled: true,
}
server.StartTLS()
defer server.Close()
request := func(certificate *tls.Certificate) (int, error) {
config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
check(!config.InsecureSkipVerify, "server identity verification must remain enabled")
config.MinVersion = tls.VersionTLS13
if certificate != nil {
config.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return certificate, nil
}
}
transport := &http.Transport{TLSClientConfig: config, DisableKeepAlives: true}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
request, err := http.NewRequest(http.MethodGet, server.URL, nil)
must(err)
request.Header.Set("X-Client-ID", "urn:example:reader")
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
_, err = io.Copy(io.Discard, response.Body)
must(err)
check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "client requires a verified TLS 1.3 server")
return response.StatusCode, nil
}
for _, test := range []struct {
name string
certificate *tls.Certificate
status int
}{
{"reader", &reader, 200}, {"guest", &guest, 403},
{"missing", nil, 0}, {"foreign issuer", &foreign, 0},
{"wrong usage", &serverOnly, 0}, {"reader again", &reader, 200},
} {
status, err := request(test.certificate)
if test.status == 0 {
check(err != nil && status == 0, "invalid client must fail before HTTP")
} else {
must(err)
check(status == test.status, "wrong authorization result")
}
fmt.Println(test.name, "HTTP status:", status)
}
check(handled.Load() == 3, "only reader, guest, and reader again may reach HTTP")
fmt.Println("three TLS rejections; authenticated guest denied despite claimed reader header")
fmt.Println(runtime.Version())
}
The reader returns 200; a guest signed by the same trusted CA returns 403 despite claiming the reader identity in X-Client-ID. Authorization reads the verified certificate’s exact URI SAN, not that header. The fixture’s URNs are application labels, not network addresses, and only urn:example:reader is authorized.
Missing credentials, a foreign issuer, and server-only extended key usage each fail before HTTP. The zero printed for them is an internal no-response marker, not an HTTP status code. Separate X.509 checks confirm UnknownAuthorityError and IncompatibleUsage for the two invalid certificate fixtures. The final reader succeeds again, and exactly three requests reach the handler.
The callback deliberately offers each supplied client credential, including the foreign one, rather than silently selecting no certificate from the server’s advertised CA preferences. This exercises the server rejection path. Production selection should respect the certificate request. Each request uses a fresh transport with a two-second timeout; connection reuse and session tickets are disabled.
All assertions passed with Go 1.27.0 on macOS. This tests actual mutual TLS plus one explicit authorization rule, not hardware-backed identity or a production PKI. It does not test revocation delivery, automated renewal, expired clients during a handshake, resumed sessions, reverse-proxy identity forwarding, or authorization changes on existing connections. Those remain separate deployment checks.
Further reading
Sources
- RFC 8446: TLS 1.3, certificate requests and proof of private-key control, checked 2026-09-09.
- Go crypto/tls, client-authentication modes, trust pools, and certificate selection, checked 2026-09-09.
- Go crypto/x509, client-authentication usage, verified chains, and revocation limitations, checked 2026-09-09.
- Go net/http/httptest, isolated TLS servers and trusted test clients, checked 2026-09-09.